Ver código fonte

Merge branch 'dev'

# Conflicts:
#	.gitignore
#	README.md
#	agent/pom.xml
#	agent/src/main/java/cn/reghao/devops/agent/AgentApp.java
#	agent/src/main/java/cn/reghao/devops/agent/config/DagentConfig.java
#	agent/src/main/resources/logback.xml
#	common/pom.xml
#	common/src/main/java/cn/reghao/devops/common/agent/app/iface/AppStat.java
#	common/src/main/java/cn/reghao/devops/common/agent/app/iface/impl/DockerApp.java
#	common/src/main/java/cn/reghao/devops/common/agent/machine/MachineEvent.java
#	common/src/main/java/cn/reghao/devops/common/docker/Docker.java
#	common/src/main/java/cn/reghao/devops/common/docker/DockerImpl.java
#	common/src/main/java/cn/reghao/devops/common/machine/Disk.java
#	common/src/main/java/cn/reghao/devops/common/machine/Memory.java
#	common/src/main/java/cn/reghao/devops/common/msg/constant/NodeStatus.java
#	common/src/main/java/cn/reghao/devops/common/msg/constant/PackType.java
#	common/src/main/java/cn/reghao/devops/common/msg/event/EvtAppDeploy.java
#	common/src/main/java/cn/reghao/devops/common/msg/event/EvtAppStatResult.java
#	common/src/main/java/cn/reghao/devops/common/version/AppVersion.java
#	common/src/main/java/cn/reghao/devops/common/ws/WebSocketListenerImpl.java
#	logstash/src/main/java/cn/reghao/devops/logstash/service/FileReader.java
#	logstash/src/main/java/cn/reghao/devops/logstash/service/TailReader.java
#	pom.xml
reghao 1 ano atrás
pai
commit
d6a501beee
100 arquivos alterados com 4098 adições e 142 exclusões
  1. 3 2
      .gitignore
  2. 39 1
      README.md
  3. 8 0
      agent/Dockerfile
  4. 4 0
      agent/bin/devopsagent.json
  5. 24 0
      agent/bin/restart.sh
  6. 22 0
      agent/bin/shutdown.sh
  7. 5 0
      agent/bin/start.sh
  8. 0 6
      agent/pom.xml
  9. 18 15
      agent/src/main/java/cn/reghao/devops/agent/AgentApp.java
  10. 2 2
      agent/src/main/java/cn/reghao/devops/agent/config/DagentConfig.java
  11. 41 0
      agent/src/main/java/cn/reghao/devops/agent/task/ImageCleanTask.java
  12. 94 0
      agent/src/main/java/cn/reghao/devops/agent/ws/WebSocketListenerImpl.java
  13. 110 0
      agent/src/main/java/cn/reghao/devops/agent/ws/WsClient.java
  14. 51 0
      agent/src/main/java/cn/reghao/devops/agent/ws/event/EventCenter.java
  15. 51 0
      agent/src/main/java/cn/reghao/devops/agent/ws/event/handler/EvtAppDeployHandler.java
  16. 73 0
      agent/src/main/java/cn/reghao/devops/agent/ws/event/handler/EvtAppStatHandler.java
  17. 3 1
      agent/src/main/resources/logback.xml
  18. 0 27
      common/pom.xml
  19. 3 0
      common/src/main/java/cn/reghao/devops/common/agent/app/iface/AppStat.java
  20. 28 0
      common/src/main/java/cn/reghao/devops/common/agent/app/iface/impl/AppDeployImpl.java
  21. 73 0
      common/src/main/java/cn/reghao/devops/common/agent/app/iface/impl/AppStatImpl.java
  22. 44 6
      common/src/main/java/cn/reghao/devops/common/agent/app/iface/impl/DockerApp.java
  23. 20 5
      common/src/main/java/cn/reghao/devops/common/agent/machine/MachineEvent.java
  24. 8 4
      common/src/main/java/cn/reghao/devops/common/docker/Docker.java
  25. 223 59
      common/src/main/java/cn/reghao/devops/common/docker/DockerImpl.java
  26. 46 0
      common/src/main/java/cn/reghao/devops/common/docker/model/Config.java
  27. 20 0
      common/src/main/java/cn/reghao/devops/common/docker/model/DockerAuth.java
  28. 11 0
      common/src/main/java/cn/reghao/devops/common/docker/model/ExposedPorts.java
  29. 15 0
      common/src/main/java/cn/reghao/devops/common/docker/model/Healthcheck.java
  30. 23 0
      common/src/main/java/cn/reghao/devops/common/docker/model/HostConfig.java
  31. 14 0
      common/src/main/java/cn/reghao/devops/common/docker/model/Labels.java
  32. 11 0
      common/src/main/java/cn/reghao/devops/common/docker/model/NetworkingConfig.java
  33. 21 0
      common/src/main/java/cn/reghao/devops/common/docker/model/RestartPolicy.java
  34. 15 0
      common/src/main/java/cn/reghao/devops/common/docker/model/Volumes.java
  35. 1 0
      common/src/main/java/cn/reghao/devops/common/machine/Disk.java
  36. 0 1
      common/src/main/java/cn/reghao/devops/common/machine/Memory.java
  37. 27 0
      common/src/main/java/cn/reghao/devops/common/machine/MemoryStat.java
  38. 14 1
      common/src/main/java/cn/reghao/devops/common/msg/constant/NodeStatus.java
  39. 1 0
      common/src/main/java/cn/reghao/devops/common/msg/constant/PackType.java
  40. 2 0
      common/src/main/java/cn/reghao/devops/common/msg/event/EvtAppDeploy.java
  41. 2 1
      common/src/main/java/cn/reghao/devops/common/msg/event/EvtAppStatResult.java
  42. 16 0
      common/src/main/java/cn/reghao/devops/common/util/KeyValue.java
  43. 1 3
      common/src/main/java/cn/reghao/devops/common/version/AppVersion.java
  44. 0 3
      common/src/main/java/cn/reghao/devops/common/ws/WebSocketListenerImpl.java
  45. 72 0
      deployer/pom.xml
  46. 52 0
      deployer/src/main/java/cn/reghao/devops/deployer/DeployApp.java
  47. 18 0
      deployer/src/main/java/cn/reghao/devops/deployer/model/RemoteHost.java
  48. 53 0
      deployer/src/main/java/cn/reghao/devops/deployer/model/UserInfoImpl.java
  49. 236 0
      deployer/src/main/java/cn/reghao/devops/deployer/util/Sftp.java
  50. 1 1
      logstash/src/main/java/cn/reghao/devops/logstash/service/FileReader.java
  51. 1 1
      logstash/src/main/java/cn/reghao/devops/logstash/service/TailReader.java
  52. 4 3
      pom.xml
  53. 7 0
      web/bin/devopsweb.yml
  54. 24 0
      web/bin/restart.sh
  55. 22 0
      web/bin/shutdown.sh
  56. 12 0
      web/bin/start.sh
  57. 327 0
      web/pom.xml
  58. 17 0
      web/src/main/java/cn/reghao/devops/web/WebApplication.java
  59. 47 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/AccountCodeController.java
  60. 100 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/HomeController.java
  61. 93 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/MenuController.java
  62. 80 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/RoleController.java
  63. 100 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/UserController.java
  64. 59 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/page/MenuPageController.java
  65. 89 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/page/RolePageController.java
  66. 126 0
      web/src/main/java/cn/reghao/devops/web/admin/account/controller/page/UserPageController.java
  67. 18 0
      web/src/main/java/cn/reghao/devops/web/admin/account/db/repository/MenuRepository.java
  68. 13 0
      web/src/main/java/cn/reghao/devops/web/admin/account/db/repository/RoleRepository.java
  69. 19 0
      web/src/main/java/cn/reghao/devops/web/admin/account/db/repository/UserRepository.java
  70. 9 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/DataStatus.java
  71. 11 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/MenuType.java
  72. 25 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/RoleType.java
  73. 34 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/UserGender.java
  74. 32 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/AccountLoginDto.java
  75. 21 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/AccountProfile.java
  76. 20 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/AccountRole.java
  77. 31 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/CreateAccountDto.java
  78. 28 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/MenuDto.java
  79. 20 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/RoleDto.java
  80. 19 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/RsaPubkey.java
  81. 19 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/UpdatePasswordDto.java
  82. 24 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/UserUpdateDTO.java
  83. 51 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/po/Menu.java
  84. 43 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/po/Role.java
  85. 139 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/po/User.java
  86. 49 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/po/UserAuthority.java
  87. 28 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/vo/MenuTree.java
  88. 39 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/vo/RoleVO.java
  89. 36 0
      web/src/main/java/cn/reghao/devops/web/admin/account/model/vo/UserVO.java
  90. 28 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/ExceptionAuthenticationEntryPoint.java
  91. 199 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/WebSecurityConfig.java
  92. 36 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/encoder/Md5PasswordEncoder.java
  93. 13 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/exceptioin/AccountLoginException.java
  94. 33 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/filter/LoginRedirectFilter.java
  95. 48 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/form/AccountAuthFilter.java
  96. 42 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/form/AccountAuthProvider.java
  97. 126 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/form/AccountAuthToken.java
  98. 36 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/handler/AuthFailHandlerImpl.java
  99. 55 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/handler/AuthSuccessHandlerImpl.java
  100. 27 0
      web/src/main/java/cn/reghao/devops/web/admin/account/security/handler/LogoutHandlerImpl.java

+ 3 - 2
.gitignore

@@ -4,6 +4,7 @@
 *target*/
 *logs/
 *.jar
+*.log*
 *git.properties*
-*bin*/
-devops_data/
+devops_data/
+dependency-reduced-pom.*

+ 39 - 1
README.md

@@ -1,2 +1,40 @@
 # devops
-一个 DevOps 自动化系统,分为 manager 和 agent 两个应用。
+后台使用的 layui 模板来自[这个项目](https://gitee.com/aun/Timo) master 分支的 12345678 版本
+
+devops 项目模块:
+- agent
+- common
+- deployer
+- logstash
+- web
+
+## 依赖
+- jdk
+- maven
+- mysql
+
+## 架构
+devops-web 和 devops-agent 之间的关系如下图所示:
+![]()
+> agent 和 web 之间使用 websocket 进行通信
+> 
+> 每台被管理的机器上需要部署且只能一个 agent 实例
+>
+> 只能部署一个 web 实例
+
+## 构建
+构建 jar 包流程:
+- 1.使用 cd 命令切换到 devops/zzz 目录
+- 2.执行 build_jar.sh 脚本
+- 3.构建完成后, 生成的 jar 包会存放在 devops/web/bin 和 devops/agent/bin 目录中
+
+## 部署
+部署 devops-web 流程:
+- 1.修改 devops/web/bin/devopsweb.yml 文件中的 mysql 配置
+- 2.使用 cd 命令切换到 devops/web/bin 目录
+- 3.执行 start.sh 脚本
+
+部署 devops-agent 流程:
+- 1.修改 devops/agent/bin/devopsagent.json 文件中的配置
+- 2.使用 cd 命令切换到 devops/agent/bin 目录
+- 3.执行 start.sh 脚本

+ 8 - 0
agent/Dockerfile

@@ -0,0 +1,8 @@
+FROM adoptopenjdk/openjdk11:x86_64-debianslim-jdk-11.0.24_8-slim
+
+WORKDIR /app
+RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
+COPY bin/devops-agent.jar /app/devops-agent.jar
+COPY bin/agent.json /app/agent.json
+
+ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-Djava.awt.headless=true","-jar","/app/devops-agent.jar", "agent.json"]

+ 4 - 0
agent/bin/devopsagent.json

@@ -0,0 +1,4 @@
+{
+  "managerAddress": "localhost",
+  "managerPort": 4020
+}

+ 24 - 0
agent/bin/restart.sh

@@ -0,0 +1,24 @@
+#!/bin/bash
+
+app_dir=`pwd`
+app_name='devops-agent.jar'
+
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+echo "process id: "${pid}
+if [[ -z ${pid} ]];
+then
+  echo "process killed"
+else
+  kill -15 ${pid}
+fi
+
+echo "sleep 10s and wait process killed"
+sleep 10
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+if [[ -z ${pid} ]];
+then
+  echo "${app_name} has killed, restart now"
+  nohup java -jar ${app_dir}"/"${app_name} ${app_dir}/agent.json > console.log 2>&1 &
+else
+  echo "process ${pid} not killed"
+fi

+ 22 - 0
agent/bin/shutdown.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+
+app_name='devops-agent.jar'
+
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+echo "process id: "${pid}
+if [[ -z ${pid} ]];
+then
+  echo "process killed"
+else
+  kill -15 ${pid}
+fi
+
+echo "sleep 10s and wait process killed"
+sleep 10
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+if [[ -z ${pid} ]];
+then
+  echo "${app_name} has killed"
+else
+  echo "process ${pid} not killed"
+fi

+ 5 - 0
agent/bin/start.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+
+app_dir=`pwd`
+app_name='devops-agent.jar'
+java -jar ${app_dir}"/"${app_name} ${app_dir}/devopsagent.json > console.log 2>&1 &

+ 0 - 6
agent/pom.xml

@@ -26,12 +26,6 @@
             <artifactId>common</artifactId>
             <version>1.0.0-SNAPSHOT</version>
         </dependency>
-
-        <dependency>
-            <groupId>com.squareup.okhttp3</groupId>
-            <artifactId>okhttp</artifactId>
-            <version>4.10.0</version>
-        </dependency>
     </dependencies>
 
     <build>

+ 18 - 15
agent/src/main/java/cn/reghao/devops/agent/AgentApp.java

@@ -3,18 +3,21 @@ package cn.reghao.devops.agent;
 import ch.qos.logback.classic.Level;
 import ch.qos.logback.classic.Logger;
 import ch.qos.logback.classic.LoggerContext;
+import cn.reghao.devops.agent.ws.WsClient;
 import cn.reghao.devops.agent.config.ConfigFile;
 import cn.reghao.devops.agent.config.DagentConfig;
-import cn.reghao.devops.agent.config.ManagerConfig;
-import cn.reghao.devops.agent.event.ws.WsClient;
+import cn.reghao.devops.agent.task.ImageCleanTask;
 import cn.reghao.devops.common.agent.app.iface.AppDeploy;
 import cn.reghao.devops.common.agent.app.iface.AppStat;
-import cn.reghao.devops.common.agent.app.iface.impl.AppDeployService;
-import cn.reghao.devops.common.agent.app.iface.impl.AppStatService;
+import cn.reghao.devops.common.agent.app.iface.impl.AppDeployImpl;
+import cn.reghao.devops.common.agent.app.iface.impl.AppStatImpl;
 import cn.reghao.devops.common.agent.app.iface.impl.DockerApp;
+import cn.reghao.devops.common.docker.Docker;
+import cn.reghao.devops.common.docker.DockerImpl;
 import cn.reghao.devops.common.msg.MessageSender;
 import cn.reghao.jutil.jdk.serializer.JsonConverter;
 import cn.reghao.jutil.jdk.string.StringRegexp;
+import cn.reghao.jutil.jdk.thread.ThreadPoolWrapper;
 import cn.reghao.jutil.jdk.util.SingleInstance;
 import lombok.extern.slf4j.Slf4j;
 import org.slf4j.LoggerFactory;
@@ -25,10 +28,14 @@ import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.Socket;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 
 @Slf4j
 public class AgentApp {
 	static MessageSender messageSender;
+	static ScheduledExecutorService scheduler = ThreadPoolWrapper.scheduledThreadPool("heartbeat", 5);
+	static Docker docker = new DockerImpl();
 
 	static void setLogLevel() {
 		LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
@@ -58,18 +65,12 @@ public class AgentApp {
 	}
 
 	static MessageSender getMessageSender(DagentConfig dagentConfig) {
-		DockerApp dockerApp = new DockerApp();
-		AppDeploy appDeploy = new AppDeployService(dockerApp);
-		AppStat appStat = new AppStatService(dockerApp);
+		DockerApp dockerApp = new DockerApp(docker);
+		AppDeploy appDeploy = new AppDeployImpl(dockerApp);
+		AppStat appStat = new AppStatImpl(dockerApp);
 
-		ManagerConfig managerConfig = dagentConfig.getManager();
-		if (managerConfig == null) {
-			log.error("配置文件中必须包含 manager 配置");
-			return null;
-		}
-
-		if (tryConnect(managerConfig.getHost(), managerConfig.getPort())) {
-			messageSender = new WsClient(dagentConfig, appDeploy, appStat);
+		if (tryConnect(dagentConfig.getManagerAddress(), dagentConfig.getManagerPort())) {
+			messageSender = new WsClient(dagentConfig, scheduler, appDeploy, appStat);
 			return messageSender;
 		}
 
@@ -105,6 +106,8 @@ public class AgentApp {
 			messageSender.connect();
 		}
 
+		ImageCleanTask cleanTask = new ImageCleanTask(docker);
+		scheduler.scheduleAtFixedRate(cleanTask, 1, 12, TimeUnit.HOURS);
 		shutdownGracefully();
 		SingleInstance.onlyOne(60001);
 	}

+ 2 - 2
agent/src/main/java/cn/reghao/devops/agent/config/DagentConfig.java

@@ -10,6 +10,6 @@ import lombok.Getter;
 @AllArgsConstructor
 @Getter
 public class DagentConfig {
-    private ManagerConfig manager;
-    private Integer heartbeatInterval;
+    private String managerAddress;
+    private int managerPort;
 }

+ 41 - 0
agent/src/main/java/cn/reghao/devops/agent/task/ImageCleanTask.java

@@ -0,0 +1,41 @@
+package cn.reghao.devops.agent.task;
+
+import cn.reghao.devops.common.docker.Docker;
+import com.github.dockerjava.api.command.InspectContainerResponse;
+import com.github.dockerjava.api.model.Image;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2024-07-29 16:17:47
+ */
+@Slf4j
+public class ImageCleanTask implements Runnable {
+    private final Docker docker;
+
+    public ImageCleanTask(Docker docker) {
+        this.docker = docker;
+    }
+
+    @Override
+    public void run() {
+        Map<String, Image> map = docker.images().stream()
+                .collect(Collectors.toMap(Image::getId, image -> image));
+
+        List<InspectContainerResponse> list = docker.psAll();
+        for (InspectContainerResponse response : list) {
+            String imageId = response.getImageId();
+            Boolean running = response.getState().getRunning();
+            if (running != null && running) {
+                Image image = map.remove(imageId);
+            }
+        }
+
+        //map.keySet().forEach(docker::imageRm);
+        log.info("定时扫描 docker 镜像和容器");
+    }
+}

+ 94 - 0
agent/src/main/java/cn/reghao/devops/agent/ws/WebSocketListenerImpl.java

@@ -0,0 +1,94 @@
+package cn.reghao.devops.agent.ws;
+
+import cn.reghao.devops.agent.ws.event.EventCenter;
+import cn.reghao.devops.common.agent.machine.MachineEvent;
+import cn.reghao.jutil.jdk.serializer.JdkSerializer;
+import cn.reghao.jutil.jdk.event.message.EventMessage;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.Response;
+import okhttp3.WebSocket;
+import okhttp3.WebSocketListener;
+import okio.ByteString;
+
+/**
+ * @author reghao
+ * @date 2023-02-23 09:26:50
+ */
+@Slf4j
+public class WebSocketListenerImpl extends WebSocketListener {
+    private final EventCenter eventCenter;
+    private final WsClient wsClient;
+    private final MachineEvent machineEvent;
+
+    public WebSocketListenerImpl(WsClient wsClient, EventCenter eventCenter, MachineEvent machineEvent) {
+        this.eventCenter = eventCenter;
+        this.wsClient = wsClient;
+        this.machineEvent = machineEvent;
+    }
+
+    @Override
+    public void onOpen(WebSocket webSocket, Response response) {
+        log.info("WebSocket 连接成功");
+        wsClient.setConnected(true);
+        wsClient.resetRetryCount();
+
+        machineEvent.agentStart();
+    }
+
+    @Override
+    public void onClosing(WebSocket webSocket, int code, String reason) {
+        log.error("WebSocket 连接被动断开 -> {} - {}", code, reason);
+        wsClient.setConnected(false);
+
+        machineEvent.pauseHeartbeat();
+        if (wsClient.isRetry()) {
+            reconnect();
+        }
+    }
+
+    @Override
+    public void onClosed(WebSocket webSocket, int code, String reason) {
+        log.error("WebSocket 连接主动断开 -> {} - {}", code, reason);
+        wsClient.setConnected(false);
+    }
+
+    @Override
+    public void onFailure(WebSocket webSocket, Throwable throwable, Response response) {
+        log.info("WebSocket 异常事件: {}", throwable.getMessage());
+        wsClient.setConnected(false);
+        if (wsClient.isRetry()) {
+            reconnect();
+        }
+    }
+
+    private void reconnect() {
+        log.info("WebSocket 重连");
+        try {
+            if (wsClient.getRetryCount() > 10) {
+                log.info("WebSocket 重连超过 10 次, 休眠 1 分钟后再尝试");
+                Thread.sleep(60_000);
+                wsClient.resetRetryCount();
+            } else {
+                log.info("休眠 10s 后再尝试重连");
+                Thread.sleep(10_000);
+            }
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        }
+        wsClient.retryCountIncr();
+        wsClient.connect();
+    }
+
+    @Override
+    public void onMessage(WebSocket webSocket, String text) {
+    }
+
+    @Override
+    public void onMessage(WebSocket webSocket, ByteString bytes) {
+        Object object = JdkSerializer.deserialize(bytes.toByteArray());
+        if (object instanceof EventMessage) {
+            EventMessage eventMessage = (EventMessage) object;
+            eventCenter.dispatch(eventMessage);
+        }
+    }
+}

+ 110 - 0
agent/src/main/java/cn/reghao/devops/agent/ws/WsClient.java

@@ -0,0 +1,110 @@
+package cn.reghao.devops.agent.ws;
+
+import cn.reghao.devops.agent.config.DagentConfig;
+import cn.reghao.devops.agent.ws.event.EventCenter;
+import cn.reghao.devops.common.agent.app.iface.AppDeploy;
+import cn.reghao.devops.common.agent.app.iface.AppStat;
+import cn.reghao.devops.common.agent.machine.MachineEvent;
+import cn.reghao.devops.common.machine.Machine;
+import cn.reghao.devops.common.msg.MessageSender;
+import cn.reghao.jutil.jdk.serializer.JdkSerializer;
+import okhttp3.*;
+import okio.ByteString;
+
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * @author reghao
+ * @date 2023-02-23 09:26:50
+ */
+public class WsClient implements MessageSender {
+    private final String url;
+    private WebSocket webSocket;
+    private boolean connected;
+    private final WebSocketListener webSocketListener;
+    private boolean retry;
+    private int retryCount;
+
+    public WsClient(DagentConfig dagentConfig, ScheduledExecutorService scheduler, AppDeploy appDeploy, AppStat appStat) {
+        String host = dagentConfig.getManagerAddress();
+        int port = dagentConfig.getManagerPort();
+        this.url = String.format("ws://%s:%s/ws/agent?token=%s", host, port, Machine.ID);
+
+        EventCenter eventCenter = new EventCenter(this, appDeploy, appStat);
+        // 每 60s 发送一次心跳
+        int heartbeatInterval = 60;
+        MachineEvent machineEvent = new MachineEvent(this, new Machine(), appStat, scheduler, heartbeatInterval);
+        this.webSocketListener = new WebSocketListenerImpl(this, eventCenter, machineEvent);
+        this.retry = true;
+        this.retryCount = 0;
+        Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);
+    }
+
+    public void setRetry(boolean retry) {
+        this.retry = retry;
+    }
+
+    public boolean isRetry() {
+        return retry;
+    }
+
+    public void retryCountIncr() {
+        this.retryCount += 1;
+    }
+
+    public void resetRetryCount() {
+        this.retryCount = 0;
+    }
+
+    public int getRetryCount() {
+        return retryCount;
+    }
+
+    @Override
+    public void connect() {
+        Request request = new Request.Builder()
+                .url(url)
+                .header("Authorization", "Bearer " + Machine.ID)
+                .build();
+
+        OkHttpClient okHttpClient = new OkHttpClient.Builder()
+                //.pingInterval(30, TimeUnit.SECONDS)
+                .connectTimeout(60, TimeUnit.SECONDS)
+                .readTimeout(60, TimeUnit.SECONDS)
+                .writeTimeout(60, TimeUnit.SECONDS)
+                .build();
+
+        if (this.webSocket != null) {
+            this.webSocket.cancel();
+        }
+
+
+        this.webSocket = okHttpClient.newWebSocket(request, webSocketListener);
+    }
+
+    @Override
+    public void setConnected(boolean status) {
+        this.connected = status;
+    }
+
+    @Override
+    public boolean isConnected() {
+        return connected;
+    }
+
+    @Override
+    public void send(String dest, Object message) {
+        if (isConnected()) {
+            byte[] bytes = JdkSerializer.serialize(message);
+            webSocket.send(ByteString.of(bytes));
+        }
+    }
+
+    public void close() {
+        setRetry(false);
+        webSocket.close(1000, "Client Close Connection");
+    }
+}

+ 51 - 0
agent/src/main/java/cn/reghao/devops/agent/ws/event/EventCenter.java

@@ -0,0 +1,51 @@
+package cn.reghao.devops.agent.ws.event;
+
+import cn.reghao.devops.agent.ws.event.handler.EvtAppDeployHandler;
+import cn.reghao.devops.agent.ws.event.handler.EvtAppStatHandler;
+import cn.reghao.devops.common.agent.app.iface.AppDeploy;
+import cn.reghao.devops.common.agent.app.iface.AppStat;
+import cn.reghao.devops.common.msg.MessageSender;
+import cn.reghao.devops.common.msg.event.EvtAppDeploy;
+import cn.reghao.devops.common.msg.event.EvtAppStat;
+import cn.reghao.jutil.jdk.event.message.Event;
+import cn.reghao.jutil.jdk.event.router.EventDispatcher;
+import cn.reghao.jutil.jdk.event.message.EventMessage;
+import lombok.extern.slf4j.Slf4j;
+
+import java.lang.management.ManagementFactory;
+
+/**
+ * @author reghao
+ * @date 2023-02-23 09:18:11
+ */
+@Slf4j
+public class EventCenter {
+    private final long startTime;
+    private final EventDispatcher dispatcher;
+
+    public EventCenter(MessageSender messageSender, AppDeploy appDeploy, AppStat appStat) {
+        this.startTime = ManagementFactory.getRuntimeMXBean().getStartTime();
+        this.dispatcher = new EventDispatcher();
+        initDispatcher(messageSender, appDeploy, appStat);
+    }
+
+    private void initDispatcher(MessageSender messageSender, AppDeploy appDeploy, AppStat appStat) {
+        dispatcher.register(EvtAppDeploy.class, new EvtAppDeployHandler(messageSender, appDeploy));
+        dispatcher.register(EvtAppStat.class, new EvtAppStatHandler(messageSender, appStat));
+    }
+
+    public void dispatch(EventMessage eventMessage) {
+        try {
+            long sendTime = eventMessage.getSendTime();
+            if (sendTime < startTime) {
+                log.info("忽略 agent 启动前 manager 发送的事件...");
+                return;
+            }
+
+            Event event = eventMessage.getEvent();
+            dispatcher.dispatch(event);
+        } catch (Exception e) {
+            log.error("处理消息发生异常: {}", e.getMessage());
+        }
+    }
+}

+ 51 - 0
agent/src/main/java/cn/reghao/devops/agent/ws/event/handler/EvtAppDeployHandler.java

@@ -0,0 +1,51 @@
+package cn.reghao.devops.agent.ws.event.handler;
+
+import cn.reghao.devops.common.agent.app.iface.AppDeploy;
+import cn.reghao.devops.common.machine.Machine;
+import cn.reghao.devops.common.msg.MessageSender;
+import cn.reghao.devops.common.msg.event.EvtAppStatResult;
+import cn.reghao.jutil.jdk.event.message.Event;
+import cn.reghao.jutil.jdk.event.message.EventMessage;
+import cn.reghao.jutil.jdk.event.handler.Handler;
+import cn.reghao.devops.common.msg.event.EvtAppDeploy;
+import cn.reghao.jutil.jdk.exception.ExceptionUtil;
+import cn.reghao.jutil.jdk.result.Result;
+import cn.reghao.jutil.jdk.result.ResultStatus;
+
+import java.io.IOException;
+
+/**
+ * @author reghao
+ * @date 2023-03-01 10:52:04
+ */
+public class EvtAppDeployHandler extends Handler {
+    private final MessageSender messageSender;
+    private final AppDeploy appDeploy;
+
+    public EvtAppDeployHandler(MessageSender messageSender, AppDeploy appDeploy) {
+        this.messageSender = messageSender;
+        this.appDeploy = appDeploy;
+    }
+
+    @Override
+    public void handle(Event evt) {
+        EvtAppDeploy deployParam = (EvtAppDeploy) evt;
+        String appId = deployParam.getAppId();
+        EvtAppStatResult statResult;
+        try {
+            statResult = appDeploy.deploy(deployParam);
+            statResult.setResult(Result.result(ResultStatus.SUCCESS));
+        } catch (Exception e) {
+            statResult = new EvtAppStatResult(appId, Machine.ID);
+            statResult.setResult(Result.result(ResultStatus.FAIL, ExceptionUtil.errorMsg(e)));
+        }
+
+        statResult.setDeploy(true);
+        EventMessage evtMsg = EventMessage.evt(statResult);
+        try {
+            messageSender.send("", evtMsg);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 73 - 0
agent/src/main/java/cn/reghao/devops/agent/ws/event/handler/EvtAppStatHandler.java

@@ -0,0 +1,73 @@
+package cn.reghao.devops.agent.ws.event.handler;
+
+import cn.reghao.devops.common.agent.app.iface.AppStat;
+import cn.reghao.devops.common.machine.Machine;
+import cn.reghao.devops.common.msg.MessageSender;
+import cn.reghao.devops.common.msg.constant.AppStatOps;
+import cn.reghao.devops.common.msg.event.EvtAppStat;
+import cn.reghao.devops.common.msg.event.EvtAppStatResult;
+import cn.reghao.jutil.jdk.event.handler.Handler;
+import cn.reghao.jutil.jdk.event.message.Event;
+import cn.reghao.jutil.jdk.event.message.EventMessage;
+import cn.reghao.jutil.jdk.exception.ExceptionUtil;
+import cn.reghao.jutil.jdk.result.Result;
+import cn.reghao.jutil.jdk.result.ResultStatus;
+
+import java.io.IOException;
+
+/**
+ * @author reghao
+ * @date 2023-03-01 10:21:40
+ */
+public class EvtAppStatHandler extends Handler {
+    private final MessageSender messageSender;
+    private final AppStat appStat;
+
+    public EvtAppStatHandler(MessageSender messageSender, AppStat appStat) {
+        this.messageSender = messageSender;
+        this.appStat = appStat;
+    }
+
+    @Override
+    public void handle(Event evt) {
+        EvtAppStat evtAppStat = (EvtAppStat) evt;
+        String appId = evtAppStat.getAppId();
+        String ops = evtAppStat.getOps();
+
+        EvtAppStatResult statResult;
+        try {
+            switch (AppStatOps.valueOf(ops)) {
+                case start:
+                    statResult = appStat.start(evtAppStat);
+                    statResult.setResult(Result.result(ResultStatus.SUCCESS));
+                    break;
+                case stop:
+                    statResult = appStat.stop(evtAppStat);
+                    statResult.setResult(Result.result(ResultStatus.SUCCESS));
+                    break;
+                case restart:
+                    statResult = appStat.restart(evtAppStat);
+                    statResult.setResult(Result.result(ResultStatus.SUCCESS));
+                    break;
+                case stat:
+                    statResult = appStat.stat(evtAppStat);
+                    statResult.setResult(Result.result(ResultStatus.SUCCESS));
+                    break;
+                default:
+                    statResult = new EvtAppStatResult(appId, Machine.ID);
+                    String msg = String.format("应用状态操作类型 %s 不存在", ops);
+                    statResult.setResult(Result.result(ResultStatus.ERROR, msg));
+            }
+        } catch (Exception e) {
+            statResult = new EvtAppStatResult(appId, Machine.ID);
+            statResult.setResult(Result.result(ResultStatus.FAIL, ExceptionUtil.errorMsg(e)));
+        }
+
+        EventMessage evtMsg = EventMessage.evt(statResult);
+        try {
+            messageSender.send(Machine.ID, evtMsg);
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+    }
+}

+ 3 - 1
agent/src/main/resources/logback.xml

@@ -2,7 +2,9 @@
 <configuration>
     <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
         <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
-            <fileNamePattern>logs/log.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <fileNamePattern>
+                logs/devopsagent.%d.log
+            </fileNamePattern>
             <maxHistory>30</maxHistory>
         </rollingPolicy>
         <encoder>

+ 0 - 27
common/pom.xml

@@ -31,39 +31,12 @@
             <version>3.2.12</version>
         </dependency>
 
-        <dependency>
-            <groupId>com.google.code.gson</groupId>
-            <artifactId>gson</artifactId>
-            <version>2.8.5</version>
-        </dependency>
-
         <dependency>
             <groupId>com.github.oshi</groupId>
             <artifactId>oshi-core</artifactId>
             <version>6.4.0</version>
         </dependency>
 
-        <dependency>
-            <groupId>org.eclipse.jgit</groupId>
-            <artifactId>org.eclipse.jgit</artifactId>
-            <version>6.4.0.202211300538-r</version>
-        </dependency>
-        <dependency>
-            <groupId>org.eclipse.jgit</groupId>
-            <artifactId>org.eclipse.jgit.ssh.apache</artifactId>
-            <version>6.4.0.202211300538-r</version>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.maven</groupId>
-            <artifactId>maven-model</artifactId>
-            <version>3.6.0</version>
-        </dependency>
-        <dependency>
-            <groupId>org.apache.maven.shared</groupId>
-            <artifactId>maven-invoker</artifactId>
-            <version>3.0.1</version>
-        </dependency>
-
         <dependency>
             <groupId>com.squareup.okhttp3</groupId>
             <artifactId>okhttp</artifactId>

+ 3 - 0
common/src/main/java/cn/reghao/devops/common/agent/app/iface/AppStat.java

@@ -3,6 +3,8 @@ package cn.reghao.devops.common.agent.app.iface;
 import cn.reghao.devops.common.msg.event.EvtAppStat;
 import cn.reghao.devops.common.msg.event.EvtAppStatResult;
 
+import java.util.List;
+
 /**
  * 应用状态管理
  *
@@ -14,4 +16,5 @@ public interface AppStat {
     EvtAppStatResult stop(EvtAppStat evtAppStat) throws Exception;
     EvtAppStatResult restart(EvtAppStat evtAppStat) throws Exception;
     EvtAppStatResult stat(EvtAppStat evtAppStat) throws Exception;
+    List<EvtAppStatResult> stat() throws Exception;
 }

+ 28 - 0
common/src/main/java/cn/reghao/devops/common/agent/app/iface/impl/AppDeployImpl.java

@@ -0,0 +1,28 @@
+package cn.reghao.devops.common.agent.app.iface.impl;
+
+import cn.reghao.devops.common.agent.app.iface.AppDeploy;
+import cn.reghao.devops.common.msg.constant.PackType;
+import cn.reghao.devops.common.msg.event.EvtAppDeploy;
+import cn.reghao.devops.common.msg.event.EvtAppStatResult;
+
+/**
+ * @author reghao
+ * @date 2023-03-06 16:21:30
+ */
+public class AppDeployImpl implements AppDeploy {
+    private final DockerApp dockerApp;
+
+    public AppDeployImpl(DockerApp dockerApp) {
+        this.dockerApp = dockerApp;
+    }
+
+    @Override
+    public EvtAppStatResult deploy(EvtAppDeploy deployParam) throws Exception {
+        String packType = deployParam.getPackType();
+        if (packType.equals(PackType.docker.name())) {
+            return dockerApp.deploy(deployParam);
+        } else {
+            throw new Exception("zip 打包没有实现");
+        }
+    }
+}

+ 73 - 0
common/src/main/java/cn/reghao/devops/common/agent/app/iface/impl/AppStatImpl.java

@@ -0,0 +1,73 @@
+package cn.reghao.devops.common.agent.app.iface.impl;
+
+import cn.reghao.devops.common.agent.app.iface.AppStat;
+import cn.reghao.devops.common.msg.constant.PackType;
+import cn.reghao.devops.common.msg.event.EvtAppStat;
+import cn.reghao.devops.common.msg.event.EvtAppStatResult;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-03-06 16:19:24
+ */
+public class AppStatImpl implements AppStat {
+    private final DockerApp dockerApp;
+
+    public AppStatImpl(DockerApp dockerApp) {
+        this.dockerApp = dockerApp;
+    }
+
+    @Override
+    public EvtAppStatResult start(EvtAppStat evtAppStat) throws Exception {
+        String packType = evtAppStat.getPackType();
+        String appId = evtAppStat.getAppId();
+
+        if (packType.equals(PackType.docker.name())) {
+            return dockerApp.start(appId);
+        } else {
+            throw new Exception("zip 打包没有实现");
+        }
+    }
+
+    @Override
+    public EvtAppStatResult stop(EvtAppStat evtAppStat) throws Exception {
+        String packType = evtAppStat.getPackType();
+        String appId = evtAppStat.getAppId();
+
+        if (packType.equals(PackType.docker.name())) {
+            return dockerApp.stop(appId);
+        } else {
+            throw new Exception("zip 打包没有实现");
+        }
+    }
+
+    @Override
+    public EvtAppStatResult restart(EvtAppStat evtAppStat) throws Exception {
+        String packType = evtAppStat.getPackType();
+        String appId = evtAppStat.getAppId();
+
+        if (packType.equals(PackType.docker.name())) {
+            return dockerApp.restart(appId);
+        } else {
+            throw new Exception("zip 打包没有实现");
+        }
+    }
+
+    @Override
+    public EvtAppStatResult stat(EvtAppStat evtAppStat) throws Exception {
+        String packType = evtAppStat.getPackType();
+        String appId = evtAppStat.getAppId();
+
+        if (packType.equals(PackType.docker.name())) {
+            return dockerApp.stat(appId);
+        } else {
+            throw new Exception("zip 打包没有实现");
+        }
+    }
+
+    @Override
+    public List<EvtAppStatResult> stat() throws Exception {
+        return dockerApp.stat();
+    }
+}

+ 44 - 6
common/src/main/java/cn/reghao/devops/common/agent/app/iface/impl/DockerApp.java

@@ -2,8 +2,9 @@ package cn.reghao.devops.common.agent.app.iface.impl;
 
 import cn.reghao.devops.common.docker.Docker;
 import cn.reghao.devops.common.docker.DockerImpl;
-import cn.reghao.devops.common.docker.po.Config;
-import cn.reghao.devops.common.docker.po.HostConfig;
+import cn.reghao.devops.common.docker.model.Config;
+import cn.reghao.devops.common.docker.model.DockerAuth;
+import cn.reghao.devops.common.docker.model.HostConfig;
 import cn.reghao.devops.common.machine.Machine;
 import cn.reghao.devops.common.msg.constant.NodeStatus;
 import cn.reghao.devops.common.msg.event.EvtAppDeploy;
@@ -13,12 +14,21 @@ import cn.reghao.jutil.jdk.converter.DateTimeConverter;
 import cn.reghao.jutil.jdk.serializer.JsonConverter;
 import com.github.dockerjava.api.command.InspectContainerResponse;
 
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
 /**
  * @author reghao
  * @date 2023-03-06 15:26:22
  */
 public class DockerApp {
-    private final Docker docker = new DockerImpl();
+    private Map<String, Docker> map = new HashMap<>();
+
+    public DockerApp(Docker docker) {
+        this.map.put("noAuth", docker);
+    }
 
     public EvtAppStatResult deploy(EvtAppDeploy deployParam) throws Exception {
         String appId = deployParam.getAppId();
@@ -33,35 +43,62 @@ public class DockerApp {
             }
         }
 
+        Docker docker;
+        DockerAuth dockerAuth = deployParam.getDockerAuth();
+        if (dockerAuth != null) {
+            String registryUrl = dockerAuth.getRegistryUrl();
+            docker = map.get(registryUrl);
+            if (docker == null) {
+                // TODO DockerAuth 修改后 map 中的 Docker 实例无效
+                docker = new DockerImpl(dockerAuth);
+                map.put(registryUrl, docker);
+            }
+        } else {
+            docker = map.get("noAuth");
+        }
         docker.pull(packagePath);
         InspectContainerResponse containerInfo = docker.createAndRun(appId, containerConfig);
         return getStat(appId, containerInfo);
     }
 
     public EvtAppStatResult start(String appId) throws Exception {
+        Docker docker = map.get("noAuth");
         InspectContainerResponse containerInfo = docker.start(appId);
         return getStat(appId, containerInfo);
     }
 
     public EvtAppStatResult stop(String appId) throws Exception {
+        Docker docker = map.get("noAuth");
         InspectContainerResponse containerInfo = docker.stop(appId);
         return getStat(appId, containerInfo);
     }
 
     public EvtAppStatResult restart(String appId) throws Exception {
+        Docker docker = map.get("noAuth");
         InspectContainerResponse containerInfo = docker.restart(appId);
         return getStat(appId, containerInfo);
     }
 
     public EvtAppStatResult stat(String appId) throws Exception {
+        Docker docker = map.get("noAuth");
         InspectContainerResponse containerInfo = docker.inspectContainer(appId);
         return getStat(appId, containerInfo);
     }
 
+    public List<EvtAppStatResult> stat() throws Exception {
+        Docker docker = map.get("noAuth");
+        List<InspectContainerResponse> list = docker.psAll();
+        return list.stream().map(response -> {
+            String containerName = response.getName();
+            String appId = containerName.replace("/", "");
+            return getStat(appId, response);
+        }).collect(Collectors.toList());
+    }
+
     private EvtAppStatResult getStat(String appId, InspectContainerResponse containerInfo) {
         EvtAppStatResult appStatResult = new EvtAppStatResult(appId, Machine.ID);
         String image = containerInfo.getConfig().getImage();
-        if (image != null) {
+        if (image != null && image.contains(":")) {
             appStatResult.setCommitId(image.split(":")[1]);
         } else {
             appStatResult.setCommitId(NotAvailable.na.getDesc());
@@ -69,12 +106,13 @@ public class DockerApp {
 
         InspectContainerResponse.ContainerState state = containerInfo.getState();
         if (state == null || Boolean.FALSE.equals(state.getRunning())) {
-            appStatResult.setStatus(NodeStatus.Offline.name());
+            //appStatResult.setStatus(NodeStatus.Offline.name());
             appStatResult.setStartTime(null);
             appStatResult.setPid(-1);
         } else {
             String startedAt = state.getStartedAt();
-            appStatResult.setStatus(NodeStatus.Online.name());
+            //appStatResult.setStatus(NodeStatus.Online.name());
+            appStatResult.setRunning(true);
             appStatResult.setStartTime(DateTimeConverter.localDateTime(startedAt));
             appStatResult.setPid(state.getPidLong().intValue());
         }

+ 20 - 5
common/src/main/java/cn/reghao/devops/common/agent/machine/MachineEvent.java

@@ -1,15 +1,17 @@
 package cn.reghao.devops.common.agent.machine;
 
+import cn.reghao.devops.common.agent.app.iface.AppStat;
 import cn.reghao.devops.common.machine.Machine;
 import cn.reghao.devops.common.msg.MessageSender;
-import cn.reghao.devops.common.msg.MsgQueue;
 import cn.reghao.devops.common.msg.event.EvtAgentHeartbeat;
 import cn.reghao.devops.common.msg.event.EvtAgentStart;
+import cn.reghao.devops.common.msg.event.EvtAppStatResult;
 import cn.reghao.jutil.jdk.event.message.EventMessage;
 import cn.reghao.jutil.jdk.thread.ThreadPoolWrapper;
 import lombok.extern.slf4j.Slf4j;
 
 import java.io.IOException;
+import java.util.List;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -23,13 +25,16 @@ public class MachineEvent {
     private final ScheduledExecutorService scheduler;
     private ScheduledFuture<?> heartbeatFuture;
     private final Machine machine;
+    private final AppStat appStat;
     private final MessageSender messageSender;
     private final int heartbeatInterval;
     
-    public MachineEvent(MessageSender messageSender, Machine machine, int heartbeatInterval) {
-        this.scheduler = ThreadPoolWrapper.scheduledThreadPool("heartbeat", 1);
-        this.machine = machine;
+    public MachineEvent(MessageSender messageSender, Machine machine, AppStat appStat,
+                        ScheduledExecutorService scheduler, int heartbeatInterval) {
         this.messageSender = messageSender;
+        this.machine = machine;
+        this.appStat = appStat;
+        this.scheduler = scheduler;
         this.heartbeatInterval = heartbeatInterval;
     }
 
@@ -38,6 +43,16 @@ public class MachineEvent {
         EventMessage eventMessage = EventMessage.evt(evtAgentStart);
         pub(eventMessage);
 
+        try {
+            List<EvtAppStatResult> list = appStat.stat();
+            list.forEach(evtAppStatResult -> {
+                EventMessage eventMessage1 = EventMessage.evt(evtAppStatResult);
+                pub(eventMessage1);
+            });
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+
         agentHeartbeat();
     }
 
@@ -61,7 +76,7 @@ public class MachineEvent {
     private void pub(EventMessage eventMessage) {
         if (messageSender.isConnected()) {
             try {
-                messageSender.send(MsgQueue.managerTopic(), eventMessage);
+                messageSender.send("", eventMessage);
             } catch (IOException e) {
                 e.printStackTrace();
             }

+ 8 - 4
common/src/main/java/cn/reghao/devops/common/docker/Docker.java

@@ -1,9 +1,9 @@
 package cn.reghao.devops.common.docker;
 
-import cn.reghao.devops.common.docker.po.Config;
+import cn.reghao.devops.common.docker.model.Config;
 import com.github.dockerjava.api.command.InspectContainerResponse;
-import com.github.dockerjava.api.model.Container;
 import com.github.dockerjava.api.model.Image;
+import com.github.dockerjava.api.model.Version;
 
 import java.util.List;
 
@@ -14,17 +14,21 @@ import java.util.List;
  * @date 2021-10-27 04:17:38
  */
 public interface Docker {
+    void auth();
+    Version version();
     void build(String repoTag, String compileHome, String dockerfileContent) throws Exception;
-    void build(String repoTag, String compileHome) throws Exception;
     void push(String image) throws Exception;
     void pull(String repoTag) throws Exception;
     void stopAndDelete(String containerName) throws Exception;
     InspectContainerResponse createAndRun(String containerName, Config containerConfig) throws Exception;
+    void runAndRm(Config containerConfig) throws Exception;
     InspectContainerResponse start(String containerName);
     InspectContainerResponse stop(String containerName);
     InspectContainerResponse restart(String containerName);
     InspectContainerResponse inspectContainer(String containerName);
     List<Image> images();
-    List<Container> ps(boolean isAll);
+    void imageRm(String imageId);
+    void rm(String containerId);
+    List<InspectContainerResponse> psAll();
     InspectContainerResponse ps(String containerName);
 }

+ 223 - 59
common/src/main/java/cn/reghao/devops/common/docker/DockerImpl.java

@@ -1,25 +1,26 @@
 package cn.reghao.devops.common.docker;
 
-import cn.reghao.devops.common.docker.po.Config;
+import cn.reghao.devops.common.docker.model.DockerAuth;
 import cn.reghao.jutil.jdk.exception.ExceptionUtil;
-import cn.reghao.jutil.jdk.converter.DateTimeConverter;
 import cn.reghao.jutil.jdk.text.TextFile;
 import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.api.async.ResultCallback;
 import com.github.dockerjava.api.command.*;
 import com.github.dockerjava.api.model.*;
 import com.github.dockerjava.core.DefaultDockerClientConfig;
 import com.github.dockerjava.core.DockerClientConfig;
 import com.github.dockerjava.core.DockerClientImpl;
-import com.github.dockerjava.core.command.PushImageResultCallback;
 import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
 import com.github.dockerjava.transport.DockerHttpClient;
+import lombok.extern.slf4j.Slf4j;
 
+import java.io.Closeable;
 import java.io.File;
+import java.io.IOException;
 import java.time.Duration;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 /**
  * Docker 客户端
@@ -27,32 +28,59 @@ import java.util.Set;
  * @author reghao
  * @date 2021-10-27 03:41:38
  */
+@Slf4j
 public class DockerImpl implements Docker {
-    private final DockerClient dockerClient;
+    private DockerClient dockerClient;
     private final TextFile textFile = new TextFile();
+    private final String unixSock = "/var/run/docker.sock";
 
     public DockerImpl() {
         DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
-                .withDockerHost("unix:///var/run/docker.sock")
+                .withDockerHost("unix://" + unixSock)
                 .withDockerTlsVerify(false)
-                //.withDockerCertPath("/home/user/.docker")
-                //.withRegistryUsername(registryUser)
-                //.withRegistryPassword(registryPass)
+                //.withDockerCertPath(String.format("%s/.docker", System.getProperty("user.home")))
+                //.withRegistryUsername("username")
+                //.withRegistryPassword("password")
                 //.withRegistryEmail(registryMail)
                 //.withRegistryUrl(registryUrl)
                 .build();
+        init(config);
+    }
+
+    public DockerImpl(DockerAuth dockerAuth) {
+        DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
+                .withDockerHost("unix://" + unixSock)
+                .withDockerTlsVerify(false)
+                //.withDockerCertPath(String.format("%s/.docker", System.getProperty("user.home")))
+                .withRegistryUrl(dockerAuth.getRegistryUrl())
+                .withRegistryUsername(dockerAuth.getUsername())
+                .withRegistryPassword(dockerAuth.getPassword())
+                //.withRegistryEmail(registryMail)
+                .build();
+        init(config);
+    }
 
+    private void init(DockerClientConfig config) {
         DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder()
                 .dockerHost(config.getDockerHost())
                 .sslConfig(config.getSSLConfig())
                 .maxConnections(100)
-                .connectionTimeout(Duration.ofSeconds(30))
-                .responseTimeout(Duration.ofSeconds(45))
+                .connectionTimeout(Duration.ofSeconds(60))
+                .responseTimeout(Duration.ofSeconds(60))
                 .build();
-
         this.dockerClient = DockerClientImpl.getInstance(config, httpClient);
     }
 
+    @Override
+    public void auth() {
+        dockerClient.authCmd().exec();
+    }
+
+    @Override
+    public Version version() {
+        return dockerClient.versionCmd().exec();
+    }
+
     @Override
     public void build(String repoTag, String compileHome, String dockerfileContent) throws Exception {
         File dockerfile = new File(compileHome + "/Dockerfile.tmp");
@@ -63,55 +91,37 @@ public class DockerImpl implements Docker {
                     // repoTag 格式为 docker.reghao.cn/devops:12345678
                     .withTags(Set.of(repoTag))
                     .start()
-                    .awaitImageId();
+                    .awaitImageId(300, TimeUnit.SECONDS);
         } catch (Exception e) {
             throw new Exception(ExceptionUtil.errorMsg(e));
         }
     }
 
-    @Override
     public void build(String repoTag, String compileHome) throws Exception {
         String dockerfilePath = compileHome + "/Dockerfile";
         dockerClient.buildImageCmd()
                 .withDockerfile(new File(dockerfilePath))
                 .withTags(Set.of(repoTag))
                 .start()
-                .awaitImageId();
+                .awaitImageId(300, TimeUnit.SECONDS);
     }
 
     @Override
     public void push(String image) throws Exception {
         try {
-            dockerClient.pushImageCmd(image).exec(new PushImageResultCallback()).awaitCompletion();
+            ResultCallback.Adapter<PushResponseItem> callback = new PushCallback();
+            dockerClient.pushImageCmd(image)
+                    //.exec(new ResultCallback.Adapter<>())
+                    .exec(callback)
+                    .awaitCompletion(300, TimeUnit.SECONDS);
+            PushCallback pushCallback = (PushCallback) callback;
+            int code = pushCallback.getCode();
+            if (code != 0) {
+                throw new Exception(pushCallback.getMsg());
+            }
         } catch (InterruptedException e) {
             throw new Exception(ExceptionUtil.errorMsg(e));
         }
-        /*dockerClient.pushImageCmd(image).exec(new ResultCallback<PushResponseItem>() {
-            @Override
-            public void onStart(Closeable closeable) {
-
-            }
-
-            @Override
-            public void onNext(PushResponseItem object) {
-                System.out.println(object.getStatus());
-            }
-
-            @Override
-            public void onError(Throwable throwable) {
-
-            }
-
-            @Override
-            public void onComplete() {
-
-            }
-
-            @Override
-            public void close() throws IOException {
-
-            }
-        }).onComplete();*/
     }
 
     @Override
@@ -176,26 +186,127 @@ public class DockerImpl implements Docker {
     }
 
     @Override
-    public InspectContainerResponse createAndRun(String containerName, Config containerConfig) throws Exception {
+    public InspectContainerResponse createAndRun(String containerName, cn.reghao.devops.common.docker.model.Config containerConfig) throws Exception {
         stopAndDelete(containerName);
 
+        String image = containerConfig.getImage();
+        CreateContainerCmd createContainerCmd = dockerClient.createContainerCmd(image)
+                .withName(containerName);
+
+        List<String> env = containerConfig.getEnv();
+        if (env != null) {
+            createContainerCmd.withEnv(env);
+        }
+
         HostConfig hostConfig = HostConfig.newHostConfig()
                 .withNetworkMode("host")
                 .withRestartPolicy(RestartPolicy.unlessStoppedRestart());
+        if (containerConfig.getVolumes() != null) {
+            Map<String, String> map = containerConfig.getVolumes().getMap();
+            List<Bind> list = new ArrayList<>();
+            for (Map.Entry<String, String> entry : map.entrySet()) {
+                String key = entry.getKey();
+                String value = entry.getValue();
+                Volume volume2 = new Volume(value);
+                Bind bind = new Bind(key, volume2);
+                list.add(bind);
+            }
+            hostConfig.withBinds(list);
+        }
+
+        createContainerCmd.withHostConfig(hostConfig);
+        CreateContainerResponse response = createContainerCmd.exec();
+        String containerId = response.getId();
+        dockerClient.startContainerCmd(containerId).exec();
+        return dockerClient.inspectContainerCmd(containerId).exec();
+    }
+
+    public void runAndRm(cn.reghao.devops.common.docker.model.Config containerConfig) throws Exception {
         String image = containerConfig.getImage();
-        CreateContainerCmd createContainerCmd = dockerClient.createContainerCmd(image)
-                .withName(containerName)
-                .withHostConfig(hostConfig);
+        CreateContainerCmd createContainerCmd = dockerClient.createContainerCmd(image).withCmd("rm");
 
         List<String> env = containerConfig.getEnv();
         if (env != null) {
             createContainerCmd.withEnv(env);
         }
 
+        HostConfig hostConfig = HostConfig.newHostConfig()
+                .withNetworkMode("host")
+                .withRestartPolicy(RestartPolicy.noRestart());
+        if (containerConfig.getVolumes() != null) {
+            Map<String, String> map = containerConfig.getVolumes().getMap();
+            List<Bind> list = new ArrayList<>();
+            for (Map.Entry<String, String> entry : map.entrySet()) {
+                String key = entry.getKey();
+                String value = entry.getValue();
+                Volume volume2 = new Volume(value);
+                Bind bind = new Bind(key, volume2);
+                list.add(bind);
+            }
+            hostConfig.withBinds(list);
+        }
+
+        if (containerConfig.getCmd() != null) {
+            createContainerCmd.withCmd(containerConfig.getCmd());
+        }
+
+        createContainerCmd.withHostConfig(hostConfig);
         CreateContainerResponse response = createContainerCmd.exec();
         String containerId = response.getId();
         dockerClient.startContainerCmd(containerId).exec();
-        return dockerClient.inspectContainerCmd(containerId).exec();
+
+        List<String> list = new ArrayList<>();
+        LogContainerCmd logContainerCmd = dockerClient.logContainerCmd(containerId);
+        logContainerCmd.withStdOut(true).withStdErr(true).withFollowStream(true);
+        logContainerCmd.exec(new ResultCallback<Frame>() {
+            @Override
+            public void onStart(Closeable closeable) {
+            }
+
+            @Override
+            public void onNext(Frame object) {
+                if (object.getStreamType().equals(StreamType.STDERR)) {
+                    if (list.size() > 100) {
+                        list.clear();
+                    }
+                    list.add(object.toString());
+                }
+            }
+
+            @Override
+            public void onError(Throwable throwable) {
+                log.info(throwable.toString());
+            }
+
+            @Override
+            public void onComplete() {
+            }
+
+            @Override
+            public void close() throws IOException {
+            }
+        });
+
+        dockerClient.waitContainerCmd(containerId).exec(new ResultCallback.Adapter<>())
+                .awaitCompletion(300, TimeUnit.SECONDS);
+
+        InspectContainerResponse response1 = dockerClient.inspectContainerCmd(containerId).exec();
+        InspectContainerResponse.ContainerState state = response1.getState();
+        Long exitCode = state.getExitCodeLong();
+        if (Boolean.TRUE.equals(state.getRunning())) {
+            dockerClient.stopContainerCmd(containerId).exec();
+            dockerClient.removeContainerCmd(containerId).exec();
+            throw new Exception("docker build timeout");
+        } else if (exitCode != null && exitCode == 0) {
+            dockerClient.removeContainerCmd(containerId).exec();
+        } else {
+            dockerClient.removeContainerCmd(containerId).exec();
+            StringBuilder sb = new StringBuilder();
+            list.forEach(line -> {
+                sb.append(line).append(System.lineSeparator());
+            });
+            throw new Exception("docker build failed:\n" + sb.toString());
+        }
     }
 
     @Override
@@ -228,28 +339,42 @@ public class DockerImpl implements Docker {
     @Override
     public List<Image> images() {
         List<Image> images = dockerClient.listImagesCmd().exec();
-
-        images.forEach(image -> {
+        return images;
+        /*return images.stream().map(image -> {
             long created = image.getCreated();
             String[] repoTags = image.getRepoTags();
             if (repoTags.length == 0) {
-                return;
+                return null;
             }
 
+            String imageId = image.getId();
             String[] repoTag = repoTags[0].split(":");
             String repo = repoTag[0];
             String tag = repoTag[1];
-            System.out.printf("%s:%s -> %s\n", repo, tag, DateTimeConverter.format(created*1000));
-        });
+            LocalDateTime created1 = DateTimeConverter.localDateTime(created*1000);
+            return new ImageInfo(repo, tag, imageId, created1);
+        }).filter(Objects::nonNull).collect(Collectors.toList());*/
+    }
 
-        return images;
+    @Override
+    public void imageRm(String imageId) {
+        dockerClient.removeImageCmd(imageId).exec();
+    }
+
+    @Override
+    public void rm(String containerId) {
+        dockerClient.removeContainerCmd(containerId).exec();
     }
 
     @Override
-    public List<Container> ps(boolean isAll) {
-        return dockerClient.listContainersCmd()
-                .withShowAll(isAll)
+    public List<InspectContainerResponse> psAll() {
+        List<Container> list = dockerClient.listContainersCmd()
+                .withShowAll(true)
                 .exec();
+        return list.stream().map(container -> {
+            String containerId = container.getId();
+            return dockerClient.inspectContainerCmd(containerId).exec();
+        }).collect(Collectors.toList());
     }
 
     @Override
@@ -266,4 +391,43 @@ public class DockerImpl implements Docker {
         String containerId = container.getId();
         return dockerClient.inspectContainerCmd(containerId).exec();
     }
+
+    static class PushCallback extends ResultCallback.Adapter<PushResponseItem> {
+        private int code;
+        private String msg;
+
+        public PushCallback() {
+            this.code = 0;
+        }
+
+        public int getCode() {
+            return code;
+        }
+
+        public String getMsg() {
+            return msg;
+        }
+
+        @Override
+        public void onNext(PushResponseItem object) {
+            PushResponseItem.ErrorDetail errorDetail = object.getErrorDetail();
+            if (errorDetail != null) {
+                //log.info("error: {} {}", errorDetail.getCode(), errorDetail.getMessage());
+                code = 1;
+                msg = errorDetail.getMessage();
+            } else {
+                //log.info("info: {} {} {}", object.getStatus(), object.getId(), object.getProgressDetail());
+            }
+        }
+
+        @Override
+        public void onComplete() {
+            super.onComplete();
+        }
+
+        @Override
+        public void onError(Throwable throwable) {
+            throwable.printStackTrace();
+        }
+    }
 }

+ 46 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/Config.java

@@ -0,0 +1,46 @@
+package cn.reghao.devops.common.docker.model;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * docker 容器创建时的配置
+ *
+ * @author reghao
+ * @date 2020-01-14 23:11:21
+ */
+@Data
+public class Config {
+    @SerializedName("AttachStderr") private boolean attachStderr;
+    @SerializedName("AttachStdin") private boolean attachStdin;
+    @SerializedName("AttachStdout") private boolean attachStdout;
+    @SerializedName("Cmd") private List<String> cmd;
+    @SerializedName("Domainname") private String domainname;
+    @SerializedName("Env") private List<String> env;
+    @SerializedName("Healthcheck") private Healthcheck healthcheck;
+    @SerializedName("Hostname") private String hostname;
+    @SerializedName("Image") private String image;
+    @SerializedName("Labels") private Labels labels;
+    @SerializedName("MacAddress") private String macAddress;
+    @SerializedName("NetworkDisabled") private boolean networkDisabled;
+    @SerializedName("OpenStdin") private boolean openStdin;
+    @SerializedName("StdinOnce") private boolean stdinOnce;
+    @SerializedName("Tty") private boolean tty;
+    @SerializedName("User") private String user;
+    @SerializedName("Volumes") private Volumes volumes;
+    @SerializedName("WorkingDir") private String workingDir;
+    @SerializedName("StopSignal") private String stopSignal;
+    @SerializedName("StopTimeout") private int stopTimeout;
+
+    @SerializedName("Entrypoint") private String entrypoint;
+    @SerializedName("ExposedPorts") private ExposedPorts exposedPorts;
+    @SerializedName("HostConfig") private HostConfig hostConfig;
+    @SerializedName("NetworkingConfig") private NetworkingConfig networkingConfig;
+
+    public Config(String image) {
+        this.image = image;
+        this.hostConfig = new HostConfig();
+    }
+}

+ 20 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/DockerAuth.java

@@ -0,0 +1,20 @@
+package cn.reghao.devops.common.docker.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2024-07-26 17:29:56
+ */
+@AllArgsConstructor
+@Getter
+public class DockerAuth implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String registryUrl;
+    private String username;
+    private String password;
+}

+ 11 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/ExposedPorts.java

@@ -0,0 +1,11 @@
+package cn.reghao.devops.common.docker.model;
+
+import lombok.Data;
+
+/**
+ * @author reghao
+ * @date 2021-02-10 01:30:17
+ */
+@Data
+public class ExposedPorts {
+}

+ 15 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/Healthcheck.java

@@ -0,0 +1,15 @@
+package cn.reghao.devops.common.docker.model;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2021-02-25 10:02:59
+ */
+@Data
+public class Healthcheck {
+    @SerializedName("Test") private List<String> test;
+}

+ 23 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/HostConfig.java

@@ -0,0 +1,23 @@
+package cn.reghao.devops.common.docker.model;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+/**
+ * TODO 部分
+ *
+ * @author reghao
+ * @date 2020-01-14 23:16:52
+ */
+@AllArgsConstructor
+@Data
+public class HostConfig {
+    @SerializedName("NetworkMode") private String networkMode;
+    @SerializedName("RestartPolicy") private RestartPolicy restartPolicy;
+
+    public HostConfig() {
+        this.networkMode = "host";
+        this.restartPolicy = new RestartPolicy();
+    }
+}

+ 14 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/Labels.java

@@ -0,0 +1,14 @@
+package cn.reghao.devops.common.docker.model;
+
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * @author reghao
+ * @date 2021-02-10 01:28:38
+ */
+@Data
+public class Labels {
+    private Map<String, String> map;
+}

+ 11 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/NetworkingConfig.java

@@ -0,0 +1,11 @@
+package cn.reghao.devops.common.docker.model;
+
+import lombok.Data;
+
+/**
+ * @author reghao
+ * @date 2021-02-10 01:33:05
+ */
+@Data
+public class NetworkingConfig {
+}

+ 21 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/RestartPolicy.java

@@ -0,0 +1,21 @@
+package cn.reghao.devops.common.docker.model;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+/**
+ * @author reghao
+ * @date 2020-05-19 15:00:50
+ */
+@AllArgsConstructor
+@Data
+public class RestartPolicy {
+    @SerializedName("Name") private String name;
+    @SerializedName("MaximumRetryCount") private int maximumRetryCount;
+
+    public RestartPolicy() {
+        this.name = "on-failure";
+        this.maximumRetryCount = 3;
+    }
+}

+ 15 - 0
common/src/main/java/cn/reghao/devops/common/docker/model/Volumes.java

@@ -0,0 +1,15 @@
+package cn.reghao.devops.common.docker.model;
+
+import lombok.Data;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author reghao
+ * @date 2021-02-10 01:29:10
+ */
+@Data
+public class Volumes {
+    private Map<String, String> map = new HashMap<>();
+}

+ 1 - 0
common/src/main/java/cn/reghao/devops/common/machine/Disk.java

@@ -25,6 +25,7 @@ public class Disk implements MachineData<List<DiskDetail>, List<DiskStat>> {
     @Override
     public List<DiskDetail> detail() {
         return os.getFileSystem().getFileStores().stream()
+                .filter(osFileStore -> osFileStore.getVolume().startsWith("/dev/"))
                 .map(osFileStore -> {
                     String vol = osFileStore.getVolume();
                     String mount = osFileStore.getMount();

+ 0 - 1
common/src/main/java/cn/reghao/devops/common/machine/Memory.java

@@ -1,6 +1,5 @@
 package cn.reghao.devops.common.machine;
 
-import cn.reghao.devops.common.util.jvm.po.MemoryStat;
 import cn.reghao.jutil.jdk.machine.data.MachineData;
 import cn.reghao.jutil.jdk.machine.data.detail.MemoryDetail;
 import oshi.hardware.GlobalMemory;

+ 27 - 0
common/src/main/java/cn/reghao/devops/common/machine/MemoryStat.java

@@ -0,0 +1,27 @@
+package cn.reghao.devops.common.machine;
+
+import cn.reghao.jutil.jdk.converter.ByteConverter;
+import cn.reghao.jutil.jdk.converter.ByteType;
+import lombok.Data;
+
+import java.lang.management.MemoryUsage;
+
+/**
+ * @author reghao
+ * @date 2020-10-21 15:49:56
+ */
+@Data
+public class MemoryStat {
+    private String init;
+    private String max;
+    private String used;
+    private String committed;
+
+    public MemoryStat(MemoryUsage memoryUsage) {
+        ByteConverter convert = new ByteConverter();
+        this.init = convert.convertStr(ByteType.Bytes, ByteType.MiB, memoryUsage.getInit());
+        this.max = convert.convertStr(ByteType.Bytes, ByteType.MiB, memoryUsage.getMax());
+        this.used = convert.convertStr(ByteType.Bytes, ByteType.MiB, memoryUsage.getUsed());
+        this.committed = convert.convertStr(ByteType.Bytes, ByteType.MiB, memoryUsage.getCommitted());
+    }
+}

+ 14 - 1
common/src/main/java/cn/reghao/devops/common/msg/constant/NodeStatus.java

@@ -5,5 +5,18 @@ package cn.reghao.devops.common.msg.constant;
  * @date 2021-08-27 17:33:20
  */
 public enum NodeStatus {
-    Online, Offline, Timeout
+    Online(1),
+    Offline(2),
+    Timeout(3),
+    Deprecated(4);
+
+    private final Integer code;
+
+    NodeStatus(Integer code) {
+        this.code = code;
+    }
+
+    public Integer getCode() {
+        return code;
+    }
 }

+ 1 - 0
common/src/main/java/cn/reghao/devops/common/msg/constant/PackType.java

@@ -7,6 +7,7 @@ package cn.reghao.devops.common.msg.constant;
  * @date 2019-11-15 21:59:35
  */
 public enum PackType {
+    //docker, zip;
     docker, zip;
 
     public String getName() {

+ 2 - 0
common/src/main/java/cn/reghao/devops/common/msg/event/EvtAppDeploy.java

@@ -1,5 +1,6 @@
 package cn.reghao.devops.common.msg.event;
 
+import cn.reghao.devops.common.docker.model.DockerAuth;
 import cn.reghao.jutil.jdk.event.message.Event;
 import lombok.Getter;
 import lombok.Setter;
@@ -16,4 +17,5 @@ public class EvtAppDeploy extends Event {
     private String packagePath;
     private String startScript;
     private String startHome;
+    private DockerAuth dockerAuth;
 }

+ 2 - 1
common/src/main/java/cn/reghao/devops/common/msg/event/EvtAppStatResult.java

@@ -17,7 +17,7 @@ public class EvtAppStatResult extends Event {
     private String machineId;
     private String appId;
     private String commitId;
-    private String status;
+    private Boolean running;
     private LocalDateTime startTime;
     private Integer pid;
     private Boolean deploy;
@@ -26,6 +26,7 @@ public class EvtAppStatResult extends Event {
     public EvtAppStatResult(String appId, String machineId) {
         this.appId = appId;
         this.machineId = machineId;
+        this.running = false;
         this.deploy = false;
     }
 }

+ 16 - 0
common/src/main/java/cn/reghao/devops/common/util/KeyValue.java

@@ -0,0 +1,16 @@
+package cn.reghao.devops.common.util;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.Getter;
+
+/**
+ * @author reghao
+ * @date 2021-06-03 19:00:57
+ */
+@AllArgsConstructor
+@Getter
+public class KeyValue {
+    private String key;
+    private String value;
+}

+ 1 - 3
common/src/main/java/cn/reghao/devops/common/version/AppVersion.java

@@ -58,9 +58,7 @@ public class AppVersion implements Serializable {
             String commitTime = props.get("commitTime").toString();
             String buildTime = props.get("buildTime").toString();
             return new AppVersion(repo, branch, commitId, commitTime, buildTime);
-
-        } catch (IOException e) {
-            //
+        } catch (IOException ignore) {
         }
         return null;
     }

+ 0 - 3
common/src/main/java/cn/reghao/devops/common/ws/WebSocketListenerImpl.java

@@ -18,7 +18,6 @@ import java.net.ProtocolException;
 @Slf4j
 public class WebSocketListenerImpl extends WebSocketListener {
     private final WsClient wsClient;
-    private MessageDispatcher dispatcher;
 
     public WebSocketListenerImpl(WsClient wsClient) {
         this.wsClient = wsClient;
@@ -82,12 +81,10 @@ public class WebSocketListenerImpl extends WebSocketListener {
 
     @Override
     public void onMessage(WebSocket webSocket, String text) {
-        dispatcher.dispatch(text);
     }
 
     @Override
     public void onMessage(WebSocket webSocket, ByteString bytes) {
         Object object = JdkSerializer.deserialize(bytes.toByteArray());
-        dispatcher.dispatch(object);
     }
 }

+ 72 - 0
deployer/pom.xml

@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>devops</artifactId>
+        <groupId>cn.reghao.devops</groupId>
+        <version>1.0.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>deployer</artifactId>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+        <project.build.outputDir>${project.basedir}/bin</project.build.outputDir>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.github.mwiede</groupId>
+            <artifactId>jsch</artifactId>
+            <version>0.2.16</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <finalName>devops-${project.artifactId}</finalName>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+                <includes>
+                    <include>logback.xml</include>
+                </includes>
+            </resource>
+        </resources>
+
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-assembly-plugin</artifactId>
+                <version>3.2.0</version>
+                <configuration>
+                    <archive>
+                        <manifest>
+                            <mainClass>cn.reghao.devops.deployer.DeployApp</mainClass>
+                        </manifest>
+                    </archive>
+                    <descriptorRefs>
+                        <descriptorRef>jar-with-dependencies</descriptorRef>
+                    </descriptorRefs>
+                    <!-- 不设置此属性则生成的 jar 包名字会带有 jar-with-dependencies -->
+                    <appendAssemblyId>false</appendAssemblyId>
+                    <outputDirectory>${project.build.outputDir}</outputDirectory>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>make-assembly</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>single</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 52 - 0
deployer/src/main/java/cn/reghao/devops/deployer/DeployApp.java

@@ -0,0 +1,52 @@
+package cn.reghao.devops.deployer;
+
+import cn.reghao.devops.deployer.model.RemoteHost;
+import cn.reghao.devops.deployer.util.Sftp;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.File;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-02-19 14:16:27
+ */
+@Slf4j
+public class DeployApp {
+    public static void main(String[] args) {
+        if (args.length != 4) {
+            log.error("usage: java -jar devops-deployer ${app_name} ${local_dir} ${remote_dir} ${sever_file}");
+            return;
+        }
+
+        String appName = args[0];
+        String localDir = args[1];
+        File file1 = new File(localDir);
+        if (!file1.exists() || file1.isFile()) {
+            log.error("local_dir {} is not exist or is a file", localDir);
+            return;
+        }
+
+        String remoteDir = args[2];
+        String serverFile = args[3];
+        File file2 = new File(serverFile);
+        if (!file2.exists() || file2.isDirectory()) {
+            log.error("server_file {} is not exist or is a directory", serverFile);
+            return;
+        }
+
+        log.info("start deploy devops apps");
+        Sftp sftp = new Sftp();
+        List<RemoteHost> remoteHosts = sftp.getRemoteHost(serverFile);
+        for (RemoteHost remoteHost : remoteHosts) {
+            try {
+                String host = remoteHost.getHost();
+                sftp.deploy(localDir, remoteDir, remoteHost);
+                log.info("deploy {} on {} done", appName, host);
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+        }
+        log.info("deploy devops done");
+    }
+}

+ 18 - 0
deployer/src/main/java/cn/reghao/devops/deployer/model/RemoteHost.java

@@ -0,0 +1,18 @@
+package cn.reghao.devops.deployer.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * @author reghao
+ * @date 2024-02-20 09:12:10
+ */
+@AllArgsConstructor
+@Getter
+public class RemoteHost {
+    private String host;
+    private int port;
+    private String username;
+    private String password;
+    private String prikeyPath;
+}

+ 53 - 0
deployer/src/main/java/cn/reghao/devops/deployer/model/UserInfoImpl.java

@@ -0,0 +1,53 @@
+package cn.reghao.devops.deployer.model;
+
+import com.jcraft.jsch.UserInfo;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * @author reghao
+ * @date 2024-02-19 14:44:45
+ */
+/**
+ * ssh private key passphrase info
+ */
+@Slf4j
+public class UserInfoImpl implements UserInfo {
+    /**
+     * ssh private key passphrase
+     */
+    private String passphrase;
+
+    public UserInfoImpl (String passphrase) {
+        this.passphrase = passphrase;
+    }
+
+    @Override
+    public String getPassphrase() {
+        return passphrase;
+    }
+
+    @Override
+    public String getPassword() {
+        return null;
+    }
+
+    @Override
+    public boolean promptPassphrase(String s) {
+        return true;
+    }
+
+    @Override
+    public boolean promptPassword(String s) {
+        return false;
+    }
+
+    @Override
+    public boolean promptYesNo(String s) {
+        return true;
+    }
+
+    @Override
+    public void showMessage(String message) {
+        log.info ("SSH Message:{}", message);
+    }
+}

+ 236 - 0
deployer/src/main/java/cn/reghao/devops/deployer/util/Sftp.java

@@ -0,0 +1,236 @@
+package cn.reghao.devops.deployer.util;
+
+import cn.reghao.devops.deployer.model.RemoteHost;
+import cn.reghao.devops.deployer.model.UserInfoImpl;
+import cn.reghao.jutil.jdk.shell.ShellResult;
+import cn.reghao.jutil.jdk.text.TextFile;
+import com.jcraft.jsch.*;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author reghao
+ * @date 2024-02-19 11:38:54
+ */
+@Slf4j
+public class Sftp {
+    private final TextFile textFile = new TextFile();
+
+    public Session getSession(RemoteHost remoteHost) throws Exception {
+        String host = remoteHost.getHost();
+        int port = remoteHost.getPort();
+        String username = remoteHost.getUsername();
+        String password = remoteHost.getPassword();
+        String prikeyPath = remoteHost.getPrikeyPath();
+
+        JSch jsch = new JSch();
+        Session session;
+        if (port <= 0) {
+            //连接服务器,采用默认端口
+            session = jsch.getSession(username, host);
+        } else {
+            //采用指定的端口连接服务器
+            session = jsch.getSession(username, host, port);
+        }
+
+        //如果服务器连接不上,则抛出异常
+        if (session == null) {
+            throw new Exception("session is null");
+        }
+
+        if (password != null) {
+            //设置登陆主机的密码
+            session.setPassword(password);
+            //设置第一次登陆的时候提示,可选值:(ask | yes | no)
+            session.setConfig("StrictHostKeyChecking", "no");
+        } else if (prikeyPath != null) {
+            session.setConfig("PreferredAuthentications", "publickey");
+            session.setConfig("userauth.gssapi-with-mic", "no");
+            session.setConfig("StrictHostKeyChecking", "ask");
+            session.setUserInfo(new UserInfoImpl(""));
+            jsch.addIdentity(prikeyPath);
+
+            session.setConfig("UseDNS", "no");
+            session.setConfig("kex", "diffie-hellman-group1-sha1,"
+                    + "diffie-hellman-group-exchange-sha1,"
+                    + "diffie-hellman-group-exchange-sha256");
+        } else {
+            throw new Exception("password and private key not exist either");
+        }
+
+        session.connect(30_000);
+        return session;
+    }
+
+    public void upload(Session session, String local, String destDir) throws JSchException, SftpException, IOException {
+        // 创建 sftp 通信通道
+        Channel channel = session.openChannel("sftp");
+        channel.connect(5_000);
+        ChannelSftp sftp = (ChannelSftp) channel;
+        sftp.cd(destDir);
+        File localFile = new File(local);
+        if (localFile.isFile()) {
+            putFile(localFile, destDir, sftp);
+        } else {
+            for (File file : Objects.requireNonNull(localFile.listFiles())) {
+                if (file.isFile()) {
+                    putFile(file, destDir, sftp);
+                }
+            }
+        }
+
+        if (channel.isConnected()) {
+            channel.disconnect();
+        }
+    }
+
+    private void putFile(File localFile, String destDir, ChannelSftp sftp) throws IOException, SftpException {
+        String filename = localFile.getName();
+        String remoteFilePath = String.format("%s/%s", destDir, filename);
+        OutputStream outstream = sftp.put(remoteFilePath);
+
+        InputStream instream = new FileInputStream(localFile);
+        byte[] bytes = new byte[1024];
+        int n;
+        while ((n = instream.read(bytes)) != -1) {
+            outstream.write(bytes, 0, n);
+        }
+
+        outstream.flush();
+        outstream.close();
+        instream.close();
+    }
+
+    /**
+     * 在远程机器上创建目录(等价于 mkdir -p)
+     *
+     * @param
+     * @return
+     * @date 2024-02-20 09:20:11
+     */
+    public boolean mkdir(Session session, String remotePath) throws SftpException, JSchException {
+        boolean exist = true;
+        // 创建 sftp 通信通道
+        Channel channel = session.openChannel("sftp");
+        channel.connect(5_000);
+        ChannelSftp sftp = (ChannelSftp) channel;
+
+        List<String> list = new ArrayList<>();
+        String[] arr = remotePath.split("/");
+        for (int i = 0; i < arr.length; i++) {
+            String path = "/" + arr[i];
+            if (i-1 > 0) {
+                list.add(list.get(i-1) + path);
+            } else {
+                list.add(path);
+            }
+        }
+
+        for (String path : list) {
+            try {
+                sftp.stat(path);
+            } catch (SftpException e) {
+                exist = false;
+                sftp.mkdir(path);
+            }
+        }
+
+        return exist;
+    }
+
+    public ShellResult exec(Session session, String command) throws JSchException, IOException {
+        StringBuilder sb = new StringBuilder();
+        // 创建 exec 通信通道
+        ChannelExec channel = (ChannelExec) session.openChannel("exec");
+        channel.setCommand(command);
+        channel.setInputStream(null);
+        channel.setErrStream(System.err);
+        channel.connect(10_000);
+        InputStream input = channel.getInputStream();
+        try {
+            BufferedReader inputReader = new BufferedReader(new InputStreamReader(input));
+            String line;
+            while((line = inputReader.readLine()) != null) {
+                sb.append(line).append(System.lineSeparator());
+            }
+        } finally {
+            if (input != null) {
+                try {
+                    input.close();
+                } catch (Exception e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+
+        int statusCode = channel.getExitStatus();
+        ShellResult shellResult = new ShellResult(statusCode);
+        shellResult.setResult(sb.toString());
+
+        if (channel.isConnected()) {
+            channel.disconnect();
+        }
+
+        return shellResult;
+    }
+
+    public List<RemoteHost> getRemoteHost(String filePath) {
+        List<String> list = textFile.read(filePath);
+        List<RemoteHost> remoteHosts = new ArrayList<>();
+        for (int i = 1; i < list.size(); i++) {
+            // 从 csv 文件的第二行开始读, 第一行为注释
+            String line = list.get(i);
+            String[] arr = line.split(",");
+            if (arr.length == 4) {
+                String host = arr[0];
+                int port = Integer.parseInt(arr[1]);
+                String username = arr[2];
+                String password = arr[3];
+                remoteHosts.add(new RemoteHost(host, port, username, password, null));
+            } else if (arr.length == 5) {
+                String host = arr[0];
+                int port = Integer.parseInt(arr[1]);
+                String username = arr[2];
+                String prikeyPath = arr[4];
+                remoteHosts.add(new RemoteHost(host, port, username, null, prikeyPath));
+            }
+        }
+
+        return remoteHosts;
+    }
+
+    public void deploy(String localDir, String remoteDir, RemoteHost remoteHost) throws Exception {
+        Session session = getSession(remoteHost);
+        boolean exist = mkdir(session, remoteDir);
+        if (exist) {
+            String command = String.format("cd %s && sh shutdown.sh", remoteDir);
+            ShellResult shellResult = exec(session, command);
+            if (!shellResult.isSuccess()) {
+                log.info("shutdown application failed: {} - {}", shellResult.getExitCode(), shellResult.getResult());
+            } else {
+                log.info("shutdown application successfully");
+            }
+        } else {
+            log.info("remote dir {} created", remoteDir);
+        }
+
+        upload(session, localDir, remoteDir);
+        log.info("files uploaded");
+
+        String command = String.format("cd %s && sh start.sh", remoteDir);
+        ShellResult shellResult = exec(session, command);
+        if (!shellResult.isSuccess()) {
+            log.info("start application failed: {} - {}", shellResult.getExitCode(), shellResult.getResult());
+        } else {
+            log.info("start application successfully");
+        }
+
+        if (session.isConnected()) {
+            session.disconnect();
+        }
+    }
+}

+ 1 - 1
logstash/src/main/java/cn/reghao/devops/logstash/service/FileReader.java

@@ -1,7 +1,7 @@
 package cn.reghao.devops.logstash.service;
 
-import cn.reghao.devops.logstash.config.LogFile;
 import cn.reghao.devops.logstash.ws.WsClient;
+import cn.reghao.devops.logstash.config.LogFile;
 import cn.reghao.jutil.jdk.thread.ThreadPoolWrapper;
 
 import java.util.List;

+ 1 - 1
logstash/src/main/java/cn/reghao/devops/logstash/service/TailReader.java

@@ -1,7 +1,7 @@
 package cn.reghao.devops.logstash.service;
 
-import cn.reghao.devops.logstash.model.NginxLog;
 import cn.reghao.devops.logstash.ws.WsClient;
+import cn.reghao.devops.logstash.model.NginxLog;
 import cn.reghao.jutil.jdk.serializer.JsonConverter;
 import lombok.extern.slf4j.Slf4j;
 

+ 4 - 3
pom.xml

@@ -9,14 +9,15 @@
     <version>1.0.0</version>
     <modules>
         <module>common</module>
-        <module>manager</module>
+        <module>web</module>
         <module>agent</module>
         <module>logstash</module>
+        <module>deployer</module>
     </modules>
     <packaging>pom</packaging>
 
     <name>devops</name>
-    <description>devops</description>
+    <description>a devops application</description>
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -54,4 +55,4 @@
             <version>1.2.3</version>
         </dependency>
     </dependencies>
-</project>
+</project>

+ 7 - 0
web/bin/devopsweb.yml

@@ -0,0 +1,7 @@
+spring:
+  thymeleaf:
+    cache: true
+  datasource:
+    url: jdbc:mysql://192.168.0.210/reghao_devops_tdb?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8
+    username: test
+    password: Test@123456

+ 24 - 0
web/bin/restart.sh

@@ -0,0 +1,24 @@
+#!/bin/bash
+
+app_dir=`pwd`
+app_name='devops-web.jar'
+
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+echo "process id: "${pid}
+if [[ -z ${pid} ]];
+then
+  echo "process killed"
+else
+  kill -15 ${pid}
+fi
+
+echo "sleep 10s and wait process killed"
+sleep 10
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+if [[ -z ${pid} ]];
+then
+  echo "${app_name} has killed, restart now"
+  nohup java -jar ${app_dir}"/"${app_name} > console.log 2>&1 &
+else
+  echo "process ${pid} not killed"
+fi

+ 22 - 0
web/bin/shutdown.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+
+app_name='devops-web.jar'
+
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+echo "process id: "${pid}
+if [[ -z ${pid} ]];
+then
+  echo "process killed"
+else
+  kill -15 ${pid}
+fi
+
+echo "sleep 10s and wait process killed"
+sleep 10
+pid=`ps aux | grep ${app_name} | grep -v 'grep' | tr -s ' '| cut -d ' ' -f 2`
+if [[ -z ${pid} ]];
+then
+  echo "${app_name} has killed"
+else
+  echo "process ${pid} not killed"
+fi

+ 12 - 0
web/bin/start.sh

@@ -0,0 +1,12 @@
+#!/bin/bash
+
+app_dir=`pwd`
+app_name='devops-web.jar'
+#java -jar ${app_dir}"/"${app_name} > console.log 2>&1 &
+
+# 使用 mvn clean package -Dmaven.test.skip -Ptest 生成 jar
+# 运行时加载 application.yml 和 devopsweb.yml 两个配置文件, 分别位于 classpath 和文件系统路径
+# 外部指定的 devopsweb.yml 文件会覆盖 resources/application-test.yml 文件
+java -jar ${app_dir}"/"${app_name} \
+--spring.config.location=classpath:/application.yml,file:${app_dir}/devopsweb.yml \
+> console.log 2>&1 &

+ 327 - 0
web/pom.xml

@@ -0,0 +1,327 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>devops</artifactId>
+        <groupId>cn.reghao.devops</groupId>
+        <version>1.0.0</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>web</artifactId>
+    <version>1.0.0</version>
+    <packaging>jar</packaging>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <maven.compiler.source>11</maven.compiler.source>
+        <maven.compiler.target>11</maven.compiler.target>
+        <project.build.outputDir>${project.basedir}/bin</project.build.outputDir>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>2.3.9.RELEASE</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>cn.reghao.jutil</groupId>
+            <artifactId>web</artifactId>
+            <version>1.0.0-SNAPSHOT</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.reghao.devops</groupId>
+            <artifactId>common</artifactId>
+            <version>1.0.0-SNAPSHOT</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-websocket</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>javax.validation</groupId>
+            <artifactId>validation-api</artifactId>
+            <version>2.0.1.Final</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>mysql</groupId>
+            <artifactId>mysql-connector-java</artifactId>
+            <version>8.0.17</version>
+        </dependency>
+        <dependency>
+            <groupId>com.zaxxer</groupId>
+            <artifactId>HikariCP</artifactId>
+            <version>3.3.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-thymeleaf</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.thymeleaf.extras</groupId>
+            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-cache</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger2</artifactId>
+            <version>2.9.2</version>
+        </dependency>
+        <dependency>
+            <groupId>io.springfox</groupId>
+            <artifactId>springfox-swagger-ui</artifactId>
+            <version>2.9.2</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.eclipse.jgit</groupId>
+            <artifactId>org.eclipse.jgit</artifactId>
+            <version>6.4.0.202211300538-r</version>
+        </dependency>
+        <dependency>
+            <groupId>org.eclipse.jgit</groupId>
+            <artifactId>org.eclipse.jgit.ssh.apache</artifactId>
+            <version>6.4.0.202211300538-r</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-model</artifactId>
+            <version>3.6.0</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.maven.shared</groupId>
+            <artifactId>maven-invoker</artifactId>
+            <version>3.0.1</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.jcraft</groupId>
+            <artifactId>jsch</artifactId>
+            <version>0.1.55</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.sun.mail</groupId>
+            <artifactId>javax.mail</artifactId>
+            <version>1.6.2</version>
+        </dependency>
+
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>2.6</version>
+        </dependency>
+    </dependencies>
+
+    <profiles>
+        <profile>
+            <id>dev</id>
+            <properties>
+                <profile.active>dev</profile.active>
+            </properties>
+            <activation>
+                <activeByDefault>true</activeByDefault>
+            </activation>
+        </profile>
+        <profile>
+            <id>test</id>
+            <properties>
+                <profile.active>test</profile.active>
+            </properties>
+        </profile>
+    </profiles>
+
+    <build>
+        <finalName>devops-${project.artifactId}</finalName>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+                <includes>
+                    <include>banner.txt</include>
+                    <include>application.yml</include>
+                    <include>application-${profile.active}.yml</include>
+                    <include>logback-spring.xml</include>
+                    <include>git.properties</include>
+                    <!-- 前端静态资源 -->
+                    <include>static/**</include>
+                    <include>templates/**</include>
+                </includes>
+            </resource>
+        </resources>
+
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.8.1</version>
+                <configuration>
+                    <source>11</source>
+                    <target>11</target>
+                </configuration>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-resources-plugin</artifactId>
+                <version>2.4.3</version>
+                <configuration>
+                    <!-- 让 maven 不处理字体 -->
+                    <nonFilteredFileExtensions>
+                        <nonFilteredFileExtension>ttf</nonFilteredFileExtension>
+                        <nonFilteredFileExtension>woff</nonFilteredFileExtension>
+                        <nonFilteredFileExtension>woff2</nonFilteredFileExtension>
+                    </nonFilteredFileExtensions>
+                </configuration>
+            </plugin>
+
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.3.9.RELEASE</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <!-- 生成的 jar 包输出到指定目录 -->
+                    <outputDirectory>${project.build.outputDir}</outputDirectory>
+                </configuration>
+            </plugin>
+
+            <!--git-commit-id-plugin 插件,用于实现打包带git版本信息-->
+            <plugin>
+                <groupId>pl.project13.maven</groupId>
+                <artifactId>git-commit-id-plugin</artifactId>
+                <version>2.1.5</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>revision</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <!--日期格式;默认值:dd.MM.yyyy '@' HH:mm:ss z;-->
+                    <dateFormat>yyyy-MM-dd HH:mm:ss</dateFormat>
+                    <!--,构建过程中,是否打印详细信息;默认值:false;-->
+                    <verbose>true</verbose>
+                    <!-- ".git"文件路径;默认值:${project.basedir}/.git;
+                      注意: 如果是多模块(多模块)项目,则需要修改到.git文件夹的目录-->
+                    <dotGitDirectory>${project.basedir}/.git</dotGitDirectory>
+                    <!--若项目打包类型为pom,是否取消构建;默认值:true;-->
+                    <skipPoms>false</skipPoms>
+                    <!--是否生成"git.properties"文件;默认值:false;-->
+                    <generateGitPropertiesFile>true</generateGitPropertiesFile>
+                    <!--指定"git.properties"文件的存放路径(相对于${project.basedir}的一个路径);
+                    注意:该地址决定接口代码是否可以读取到git版本信息,请自行修改-->
+                    <generateGitPropertiesFilename>src/main/resources/git.properties</generateGitPropertiesFilename>
+                    <!--".git"文件夹未找到时,构建是否失败;若设置true,则构建失败;若设置false,则跳过执行该目标;默认值:true;-->
+                    <failOnNoGitDirectory>true</failOnNoGitDirectory>
+                    <!--git描述配置,可选;由JGit提供实现;-->
+                    <gitDescribe>
+                        <!--是否生成描述属性-->
+                        <skip>false</skip>
+                        <!--提交操作未发现tag时,仅打印提交操作ID,-->
+                        <always>false</always>
+                        <!--提交操作ID显式字符长度,最大值为:40;默认值:7;
+                            0代表特殊意义;后面有解释;
+                        -->
+                        <abbrev>8</abbrev>
+                        <!--构建触发时,代码有修改时(即"dirty state"),添加指定后缀;默认值:"";-->
+                        <dirty>-dirty</dirty>
+                        <!--always print using the "tag-commits_from_tag-g_commit_id-maybe_dirty" format, even if "on" a tag.
+                            The distance will always be 0 if you're "on" the tag.
+                        -->
+                        <forceLongFormat>false</forceLongFormat>
+                    </gitDescribe>
+                </configuration>
+            </plugin>
+            <!--apache maven插件,用于执行ant脚本函数-->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-antrun-plugin</artifactId>
+                <version>1.8</version>
+                <executions>
+                    <execution>
+                        <!--maven 插件生命周期,我试过用compile 发现打包以后没有git.properties,因为打包的时候,还没有生成
+                        注意,这里改成 initialize 的话, 上面 git-commit-id-plugin 插件对应也该添加 phase 配置,并设置为 initialize
+                         否则你生成的文件,写入的就不会对应的值,而是变量名-->
+                        <phase>initialize</phase>
+                        <goals>
+                            <goal>run</goal>
+                        </goals>
+                        <configuration>
+                            <tasks>
+                                <echo message="写入 git 版本信息"/>
+                                <!-- concat 常用于把一些信息写入文件,我这里用于把 git-commit-id-plugin 生成的git环境变量写入文件 -->
+                                <concat destfile="src/main/resources/git.properties">
+                                    repo=${git.remote.origin.url}
+                                    branch=${git.branch}
+                                    commitId=${git.commit.id.abbrev}
+                                    commitTime=${git.commit.time}
+                                    buildTime=${git.build.time}
+                                </concat>
+                            </tasks>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 17 - 0
web/src/main/java/cn/reghao/devops/web/WebApplication.java

@@ -0,0 +1,17 @@
+package cn.reghao.devops.web;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+
+@SpringBootApplication
+@EnableJpaRepositories
+@EntityScan({"cn.reghao.devops.web"})
+@ComponentScan(basePackages = {"cn.reghao.devops.web", "cn.reghao.devops.common"})
+public class WebApplication {
+	public static void main(String[] args) {
+		SpringApplication.run(WebApplication.class, args);
+	}
+}

+ 47 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/AccountCodeController.java

@@ -0,0 +1,47 @@
+package cn.reghao.devops.web.admin.account.controller;
+
+import cn.reghao.devops.web.admin.account.model.dto.RsaPubkey;
+import cn.reghao.devops.web.admin.account.service.CodeService;
+import cn.reghao.devops.web.admin.account.service.PubkeyService;
+import cn.reghao.jutil.jdk.result.WebResult;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+/**
+ * @author reghao
+ * @date 2022-02-18 16:05:30
+ */
+@RestController
+@RequestMapping("/api/account/code")
+public class AccountCodeController {
+    private final CodeService codeService;
+    private final PubkeyService pubkeyService;
+
+    public AccountCodeController(CodeService codeService, PubkeyService pubkeyService) {
+        this.codeService = codeService;
+        this.pubkeyService = pubkeyService;
+    }
+
+    @GetMapping(value = "/pubkey", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String getPubkey() throws NoSuchAlgorithmException {
+        RsaPubkey rsaPubkey = pubkeyService.getPubkey();
+        return WebResult.success(rsaPubkey);
+    }
+
+    @GetMapping(value = "/captcha", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String getCaptcha() throws IOException {
+        InputStream in = codeService.generateCaptcha();
+        byte[] base64Bytes = Base64.getEncoder().encode(in.readAllBytes());
+        String base64Str = new String(base64Bytes, StandardCharsets.UTF_8);
+        String imageStr = String.format("data:image/jpeg;base64,%s", base64Str);
+        return WebResult.success(imageStr);
+    }
+}

+ 100 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/HomeController.java

@@ -0,0 +1,100 @@
+package cn.reghao.devops.web.admin.account.controller;
+
+import cn.reghao.devops.common.util.NotAvailable;
+import cn.reghao.devops.common.version.AppVersion;
+import cn.reghao.devops.web.admin.account.model.constant.RoleType;
+import cn.reghao.devops.web.admin.account.model.po.Menu;
+import cn.reghao.devops.web.admin.account.model.po.User;
+import cn.reghao.devops.web.admin.account.service.HomeService;
+import cn.reghao.devops.web.admin.account.service.UserContext;
+import cn.reghao.devops.web.admin.sys.service.SysMessageService;
+import cn.reghao.jutil.jdk.jvm.JVM;
+import cn.reghao.jutil.jdk.jvm.model.JvmInfo;
+import cn.reghao.jutil.jdk.machine.id.MachineIdLinux;
+import io.swagger.annotations.Api;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * @author reghao
+ * @date 2021-04-04 21:24:18
+ */
+@Slf4j
+@Api(tags = "登录页和首页")
+@Controller
+public class HomeController {
+    private final JVM jvm;
+    private final MachineIdLinux machineId;
+    private final AppVersion appVersion;
+    private final HomeService homeService;
+    private final SysMessageService sysMessageService;
+
+    public HomeController(HomeService homeService, SysMessageService sysMessageService) {
+        this.jvm = new JVM();
+        this.machineId = new MachineIdLinux();
+        this.appVersion = AppVersion.getVersion();
+        this.homeService = homeService;
+        this.sysMessageService = sysMessageService;
+    }
+    
+    @GetMapping("/login")
+    public String toLogin(Model model) {
+        model.addAttribute("isCaptcha", false);
+        return "/login";
+    }
+
+    @GetMapping("/")
+    public String index(Model model) throws Exception {
+        User user = UserContext.getUser();
+        if (user == null) {
+            throw new Exception("未登录");
+        }
+
+        boolean isAdmin = user.getRole().getName().equals(RoleType.ROLE_ADMIN.name());
+        if (isAdmin) {
+            String unreadMessage = sysMessageService.getUnreadCount();
+            model.addAttribute("unreadMessage", unreadMessage);
+        }
+
+        // TODO 此处直接传入 Role 对象, 然后调用 Role#getMenus 会抛出 LazyInitializationException 异常
+        List<Menu> menus = homeService.userMenus(Set.of(user.getRole().getName()));
+        Map<Integer, Menu> treeMenu = homeService.treeMenu(menus);
+        model.addAttribute("user", user);
+        model.addAttribute("treeMenu", treeMenu);
+        return "/main";
+    }
+
+    @GetMapping("/home")
+    public String home(Model model) {
+        String commitId = NotAvailable.na.getDesc();
+        if (appVersion != null) {
+            commitId = appVersion.getCommitId();
+        }
+
+        JvmInfo jvmInfo = jvm.info();
+        //JvmStat jvmStat = jvm.stat();
+        String osInfo = String.format("%s %s", jvmInfo.getOsName(), jvmInfo.getOsVersion());
+        String jvmInfo1 = String.format("%s %s", jvmInfo.getJvmName(), jvmInfo.getJvmVersion());
+        int pid = jvmInfo.getJvmPid();
+        String startAt = jvmInfo.getJvmStartTime();
+        String ipv4 = machineId.ipv4();
+
+        model.addAttribute("managerVersion", commitId);
+        model.addAttribute("hostAddr", ipv4);
+        model.addAttribute("osInfo", osInfo);
+        model.addAttribute("jvmInfo", jvmInfo1);
+        model.addAttribute("pid", pid);
+        model.addAttribute("startAt", startAt);
+        //model.addAttribute("jvmStat", jvmStat);
+
+        String template = "/home/index";
+        template = "/home/index1";
+        return template;
+    }
+}

+ 93 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/MenuController.java

@@ -0,0 +1,93 @@
+package cn.reghao.devops.web.admin.account.controller;
+
+import cn.reghao.devops.web.admin.account.model.vo.MenuTree;
+import cn.reghao.devops.web.admin.account.service.MenuService;
+import cn.reghao.jutil.jdk.result.Result;
+import cn.reghao.devops.web.admin.account.model.dto.MenuDto;
+import cn.reghao.devops.web.admin.account.model.po.Menu;
+import cn.reghao.jutil.jdk.result.WebResult;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2021-04-04 21:24:18
+ */
+@Api(tags = "资源接口")
+@RequestMapping("/api/rbac/menu")
+@RestController
+public class MenuController {
+    private final MenuService menuService;
+
+    public MenuController(MenuService menuService) {
+        this.menuService = menuService;
+    }
+
+    @ApiOperation(value = "添加资源")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
+    public String addMenu(@Validated Menu menu) {
+        Result result = menuService.addMenu(menu);
+        return WebResult.result(result);
+    }
+
+    @ApiOperation(value = "修改资源")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(value = "/edit", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String modifyMenu(@Validated MenuDto menuDto) {
+        Result result = menuService.updateMenu(menuDto);
+        return WebResult.result(result);
+    }
+
+    @ApiOperation(value = "修改资源状态")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(value = "/status/{enabled}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String menuStatus(@PathVariable("enabled") boolean enabled, @RequestParam("ids") List<Integer> ids) {
+        menuService.updateMenusStatus(enabled, ids);
+        return WebResult.success();
+    }
+
+    @ApiOperation(value = "删除资源")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @DeleteMapping(value = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String deleteMenu(@PathVariable("id") Integer menuId) {
+        /*Result result = menuService.deleteMenu(menuId);
+        return WebResult.result(result);*/
+        return WebResult.failWithMsg("接口未实现");
+    }
+
+    @ApiOperation(value = "获取指定状态的菜单")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping(value = "/ztree/{enabled}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String list(@PathVariable(value = "enabled") Boolean enabled) {
+        List<MenuTree> list = menuService.getMenusByStatus(enabled);
+        return WebResult.success(list);
+    }
+
+    @ApiOperation(value = "返回所有 dir menu")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping(value = "/ztree/parent", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String getDirMenus() {
+        List<MenuTree> list = menuService.getDirMenus();
+        return WebResult.success(list);
+    }
+
+    @ApiOperation(value = "对同一 pid 组内的资源进行排序")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping(value = "/sorted/{pid}/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String sortList(@PathVariable(value = "pid") int pid,
+                           @PathVariable(value = "id", required = false) Menu menu) {
+        /*Map<Integer, String> map = menuService.getSortedChildGroupByPid(pid);
+        // 排除当前 menu
+        if (menu != null) {
+            map.remove(menu.getPos());
+        }*/
+        return WebResult.failWithMsg("接口未实现");
+    }
+}

+ 80 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/RoleController.java

@@ -0,0 +1,80 @@
+package cn.reghao.devops.web.admin.account.controller;
+
+import cn.reghao.devops.web.admin.account.model.dto.RoleDto;
+import cn.reghao.devops.web.admin.account.model.po.Menu;
+import cn.reghao.devops.web.admin.account.model.po.Role;
+import cn.reghao.devops.web.admin.account.model.vo.MenuTree;
+import cn.reghao.devops.web.admin.account.service.MenuService;
+import cn.reghao.devops.web.admin.account.service.RoleService;
+import cn.reghao.jutil.jdk.result.Result;
+import cn.reghao.jutil.jdk.result.WebResult;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * @author reghao
+ * @date 2021-04-04 21:24:18
+ */
+@Api(tags = "角色接口")
+@RequestMapping("/api/rbac/role")
+@RestController
+public class RoleController {
+    private final RoleService roleService;
+    private final MenuService menuService;
+
+    public RoleController(RoleService roleService, MenuService menuService) {
+        this.roleService = roleService;
+        this.menuService = menuService;
+    }
+
+    @ApiOperation("添加或修改角色")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
+    public String addOrModifyRole(@Validated RoleDto roleDto) {
+        roleService.addOrUpdate(roleDto);
+        return WebResult.success();
+    }
+
+    @ApiOperation("删除角色")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @DeleteMapping(value = "/{roleId}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String deleteRole(@PathVariable("roleId") Integer roleId) {
+        Result result = roleService.deleteRole(roleId);
+        return WebResult.result(result);
+    }
+
+    /**
+     * spring data jpa 会自动根据 id 参数查询 Role 实例
+     *
+     * @param
+     * @return
+     * @date 2024-07-30 16:08:42
+     */
+    @ApiOperation("获取角色可访问的资源")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping(value = "/menus/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String getRoleMenus(@PathVariable("id") Role role) {
+        List<MenuTree> allMenus = menuService.getMenusByRole(role);
+        return WebResult.success(allMenus);
+    }
+
+    @ApiOperation("设置角色可访问的资源")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(value = "/menus", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String setRoleMenus(@RequestParam(value = "id") Integer roleId,
+                               @RequestParam(value = "menuId", required = false) Set<Menu> menus) {
+        if (menus == null) {
+            menus = Collections.emptySet();
+        }
+        roleService.setRoleMenus(roleId, menus);
+        return WebResult.success();
+    }
+}

+ 100 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/UserController.java

@@ -0,0 +1,100 @@
+package cn.reghao.devops.web.admin.account.controller;
+
+import cn.reghao.devops.web.admin.account.model.dto.UpdatePasswordDto;
+import cn.reghao.devops.web.admin.account.model.dto.CreateAccountDto;
+import cn.reghao.jutil.jdk.result.Result;
+import cn.reghao.jutil.jdk.result.WebResult;
+import cn.reghao.devops.web.admin.account.model.dto.AccountProfile;
+import cn.reghao.devops.web.admin.account.model.dto.AccountRole;
+import cn.reghao.devops.web.admin.account.service.AccountService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * @author reghao
+ * @date 2021-04-04 21:24:18
+ */
+@Api(tags = "用户接口")
+@RequestMapping("/api/rbac/user")
+@RestController
+public class UserController {
+    private final AccountService accountService;
+
+    public UserController(AccountService accountService) {
+        this.accountService = accountService;
+    }
+
+    @ApiOperation(value = "修改个人头像")
+    @PostMapping(value = "/avatar", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String userAvatar(MultipartFile file) {
+        return WebResult.failWithMsg("接口未实现");
+    }
+
+    @ApiOperation(value = "修改个人信息")
+    @PostMapping(value = "/update", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String editUserInfo(@Validated AccountProfile accountProfile) {
+        accountService.updateAccountProfile(accountProfile);
+        return WebResult.success();
+    }
+
+    @ApiOperation(value = "修改个人密码")
+    @PostMapping(value = "/passwd/update", produces = MediaType.APPLICATION_JSON_VALUE)
+    @ResponseBody
+    public String editPasswd(UpdatePasswordDto updatePasswordDto) {
+        return WebResult.failWithMsg("接口未实现");
+    }
+
+    @ApiOperation(value = "创建用户")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE)
+    public String createUser(@Validated CreateAccountDto createAccountDto) {
+        Result result = accountService.createAccount(createAccountDto);
+        return WebResult.result(result);
+    }
+
+    @ApiOperation(value = "批量创建用户")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(value = "/batch", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String batchAdd(MultipartFile file) {
+        return WebResult.failWithMsg("接口未实现");
+    }
+
+    @ApiOperation(value = "删除用户")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @DeleteMapping(value = "/delete/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String deleteAccount(@PathVariable("id") Integer userId) {
+        Result result = accountService.deleteAccount(userId);
+        return WebResult.result(result);
+    }
+
+    @ApiOperation(value = "修改用户密码")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(value = "/passwd", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String modifyPassword(@NotNull Integer id, @NotNull String newPassword) {
+        accountService.updateAccountPassword(id, newPassword);
+        return WebResult.success();
+    }
+
+    @ApiOperation(value = "分配用户角色")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(value = "/role", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String assignRole(@Validated AccountRole accountRole) {
+        Result result = accountService.updateAccountRole(accountRole);
+        return WebResult.result(result);
+    }
+
+    @ApiOperation(value = "启用/禁用用户")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @PostMapping(value = "/status/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String updateAccountStatus(@PathVariable("userId") Integer userId) {
+        //userIds.forEach(userId -> accountService.updateAccountStatus(userId, enable));
+        return WebResult.failWithMsg("接口未实现");
+    }
+}

+ 59 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/page/MenuPageController.java

@@ -0,0 +1,59 @@
+package cn.reghao.devops.web.admin.account.controller.page;
+
+import cn.reghao.devops.web.admin.account.model.po.Menu;
+import cn.reghao.devops.web.admin.account.model.vo.RoleVO;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2021-04-04 21:24:18
+ */
+@Api(tags = "资源页面")
+@RequestMapping("/rbac/menu")
+@Controller
+public class MenuPageController {
+    @ApiOperation(value = "资源列表页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping
+    public String menuPage(@RequestParam(value = "enabled", required = false) Boolean enabled, Model model) {
+        if (enabled == null) {
+            enabled = true;
+        }
+
+        model.addAttribute("enabled", enabled);
+        return "/admin/menu/index";
+    }
+
+    @ApiOperation(value = "资源添加页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping(value = "/add")
+    public String addMenuPage(Model model) {
+        return "/admin/menu/add";
+    }
+
+    @ApiOperation(value = "资源编辑页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/edit/{id}")
+    public String toEdit(@PathVariable("id") Menu menu, Model model) {
+        model.addAttribute("menu", menu);
+        return "/admin/menu/edit";
+    }
+
+    // TODO Hibernate 会根据传入的 id 自动查找相应的 Menu
+    @ApiOperation(value = "可访问资源的角色列表页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/{id}/roles")
+    public String roleListWithResource(@PathVariable("id") Menu menu, Model model) {
+        List<RoleVO> list = menu.getRoles().stream().map(RoleVO::new).collect(Collectors.toList());
+        model.addAttribute("list", list);
+        return "/admin/menu/roles";
+    }
+}

+ 89 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/page/RolePageController.java

@@ -0,0 +1,89 @@
+package cn.reghao.devops.web.admin.account.controller.page;
+
+import cn.reghao.devops.web.admin.account.model.po.User;
+import cn.reghao.devops.web.admin.account.model.vo.RoleVO;
+import cn.reghao.devops.web.admin.account.service.RoleService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2021-04-04 21:24:18
+ */
+@Api(tags = "角色页面")
+@RequestMapping("/rbac/role")
+@Controller
+public class RolePageController {
+    private final RoleService roleService;
+
+    public RolePageController(RoleService roleService) {
+        this.roleService = roleService;
+    }
+
+    @ApiOperation("角色列表页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping
+    public String rolePage(@RequestParam(value = "name", required = false) String name, Model model) {
+        Page<RoleVO> page;
+        if (name != null) {
+            page = roleService.getByPage(null, name);
+        } else {
+            PageRequest pageRequest = PageRequest.of(0, 100);
+            page = roleService.getByPage(pageRequest, null);
+        }
+
+        model.addAttribute("page", page);
+        model.addAttribute("list", page.getContent());
+        return "/admin/role/index";
+    }
+
+    @ApiOperation("角色新增页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/add")
+    public String addRolePage() {
+        return "/admin/role/add";
+    }
+
+    @ApiOperation("角色编辑页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/edit/{id}")
+    public String editRolePage(@PathVariable("id") int id, Model model) {
+        RoleVO vo = roleService.getRoleVOById(id);
+        model.addAttribute("role", vo);
+        return "/admin/role/add";
+    }
+
+    @ApiOperation("角色详细信息页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/detail/{id}")
+    public String roleDetailPage(@PathVariable("id") int id, Model model) {
+        RoleVO vo = roleService.getRoleVOById(id);
+        model.addAttribute("role", vo);
+        return "/admin/role/detail";
+    }
+
+    @ApiOperation("设置角色可访问的资源页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/menus/{id}")
+    public String menusPage(@PathVariable(value = "id") Integer id, Model model){
+        model.addAttribute("roleId", id);
+        return "/admin/role/menus";
+    }
+
+    @ApiOperation("拥有角色的所有用户页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/users/{id}")
+    public String userListWithRole(@PathVariable("id") Integer roleId, Model model) {
+        List<User> list = roleService.getRoleUsers(roleId);
+        model.addAttribute("list", list);
+        return "/admin/role/users";
+    }
+}

+ 126 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/controller/page/UserPageController.java

@@ -0,0 +1,126 @@
+package cn.reghao.devops.web.admin.account.controller.page;
+
+import cn.reghao.devops.web.admin.account.model.po.User;
+import cn.reghao.devops.web.admin.account.model.vo.RoleVO;
+import cn.reghao.devops.web.admin.account.model.vo.UserVO;
+import cn.reghao.devops.web.admin.account.service.AccountService;
+import cn.reghao.devops.web.admin.account.service.AccountSessionService;
+import cn.reghao.devops.web.admin.account.service.RoleService;
+import cn.reghao.devops.web.admin.account.service.UserContext;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2021-04-04 21:24:18
+ */
+@Api(tags = "用户页面")
+@RequestMapping("/rbac/user")
+@Controller
+public class UserPageController {
+    private final AccountService accountService;
+    private final RoleService roleService;
+    private final AccountSessionService accountSessionService;
+
+    public UserPageController(AccountService accountService, RoleService roleService, AccountSessionService accountSessionService) {
+        this.accountService = accountService;
+        this.roleService = roleService;
+        this.accountSessionService = accountSessionService;
+    }
+
+    @ApiOperation(value = "个人信息页面")
+    @GetMapping("/profile")
+    public String userInfoPage(Model model) {
+        User user = UserContext.getUser();
+        model.addAttribute("user", user);
+        return "/admin/user/userinfo";
+    }
+
+    @ApiOperation(value = "修改个人密码页面")
+    @GetMapping("/passwd/edit")
+    public String editPasswdPage(Model model) {
+        return "/admin/user/userpasswd";
+    }
+
+    @ApiOperation(value = "用户列表页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping
+    public String userPage(@RequestParam(value = "screenName", required = false) String screenName, Model model) {
+        Page<UserVO> page;
+        if (screenName != null) {
+            List<UserVO> list = accountService.getByMatchScreenName(screenName);
+            page = new PageImpl<>(list);
+        } else {
+            PageRequest pageRequest = PageRequest.of(0, 100);
+            page = accountService.getUserVOByPage(pageRequest);
+        }
+
+        Map<Integer, String> map = accountSessionService.getLastAccess();
+        page.getContent().forEach(userVO -> {
+            int userId = userVO.getUserId();
+            String lastAccessStr = map.get(userId);
+            if (lastAccessStr != null) {
+                userVO.setLastAccess(lastAccessStr);
+            }
+        });
+
+        model.addAttribute("page", page);
+        model.addAttribute("list", page.getContent());
+        return "/admin/user/index";
+    }
+
+    @ApiOperation(value = "新增用户页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/add")
+    public String addUserPage(Model model) {
+        Set<RoleVO> allRoles = roleService.getAllRoles();
+        model.addAttribute("allRoles", allRoles);
+        model.addAttribute("userRoles", Collections.emptySet());
+        return "/admin/user/add";
+    }
+
+    @ApiOperation(value = "用户详细信息页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/detail/{id}")
+    public String userDetailPage(@PathVariable("id") int id, Model model) {
+        User user = accountService.getById(id);
+        Set<RoleVO> roles = roleService.getUserRoles(user.getId());
+        List<String> names = roles.stream().map(RoleVO::getName).collect(Collectors.toList());
+
+        model.addAttribute("roles", names.toString());
+        model.addAttribute("user", user);
+        return "/admin/user/detail";
+    }
+
+    @ApiOperation(value = "重置用户密码页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/passwd/{id}")
+    public String modifyPasswordPage(@PathVariable("id") Integer id, Model model) {
+        model.addAttribute("id", id);
+        return "/admin/user/passwd";
+    }
+
+    @ApiOperation(value = "用户角色分配页面")
+    @PreAuthorize("hasRole('ROLE_ADMIN')")
+    @GetMapping("/role/{id}")
+    public String assignRolePage(@PathVariable("id") User user, Model model) {
+        Set<RoleVO> allRoles = roleService.getAllRoles();
+        int userId = user.getId();
+        Set<RoleVO> userRoles = roleService.getUserRoles(userId);
+
+        model.addAttribute("id", userId);
+        model.addAttribute("list", allRoles);
+        model.addAttribute("authRoles", userRoles);
+        return "/admin/user/role";
+    }
+}

+ 18 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/db/repository/MenuRepository.java

@@ -0,0 +1,18 @@
+package cn.reghao.devops.web.admin.account.db.repository;
+
+import cn.reghao.devops.web.admin.account.model.po.Menu;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2019-08-29 10:52:14
+ */
+public interface MenuRepository extends JpaRepository<Menu, Integer> {
+    int countByPid(int pid);
+    List<Menu> findByPid(int pid);
+    Menu findByUrl(String url);
+    List<Menu> findAllByEnabled(boolean enabled);
+    List<Menu> findByEnabledIsTrueAndType(String type);
+}

+ 13 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/db/repository/RoleRepository.java

@@ -0,0 +1,13 @@
+package cn.reghao.devops.web.admin.account.db.repository;
+
+import cn.reghao.devops.web.admin.account.model.po.Role;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+/**
+ * @author reghao
+ * @date 2019-08-29 10:52:14
+ */
+public interface RoleRepository extends JpaRepository<Role, Integer>, JpaSpecificationExecutor<Role> {
+    Role findByName(String name);
+}

+ 19 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/db/repository/UserRepository.java

@@ -0,0 +1,19 @@
+package cn.reghao.devops.web.admin.account.db.repository;
+
+import cn.reghao.devops.web.admin.account.model.po.User;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2019-08-29 10:52:14
+ */
+public interface UserRepository extends JpaRepository<User, Integer>, JpaSpecificationExecutor<User> {
+    User findByUsername(String username);
+    int countByRole_id(int id);
+    Page<User> findAllByRole_id(int id, Pageable pageable);
+}

+ 9 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/DataStatus.java

@@ -0,0 +1,9 @@
+package cn.reghao.devops.web.admin.account.model.constant;
+
+/**
+ * @author reghao
+ * @date 2021-05-17 13:50:14
+ */
+public enum DataStatus {
+    ENABLE, DISABLE
+}

+ 11 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/MenuType.java

@@ -0,0 +1,11 @@
+package cn.reghao.devops.web.admin.account.model.constant;
+
+/**
+ * 菜单类型
+ *
+ * @author reghao
+ * @date 2021-04-05 02:22:44
+ */
+public enum MenuType {
+    dir, page
+}

+ 25 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/RoleType.java

@@ -0,0 +1,25 @@
+package cn.reghao.devops.web.admin.account.model.constant;
+
+/**
+ * 角色类型
+ *
+ * @author reghao
+ * @date 2021-04-05 02:22:44
+ */
+public enum RoleType {
+    ROLE_ADMIN("管理员"),
+    ROLE_USER("普通用户"),
+    ROLE_JAVA("Java 开发"),
+    ROLE_DOTNET("DotNet 开发"),
+    ROLE_NPM("前端开发");
+
+    private final String desc;
+
+    RoleType(String desc) {
+        this.desc = desc;
+    }
+
+    public String getDesc() {
+        return desc;
+    }
+}

+ 34 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/constant/UserGender.java

@@ -0,0 +1,34 @@
+package cn.reghao.devops.web.admin.account.model.constant;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 用户性别
+ *
+ * @author reghao
+ * @date 2021-07-14 10:23:46
+ */
+public enum UserGender {
+    female(0, "女"), male(1, "男"), unknown(2, "未知");
+
+    private final Integer code;
+    private final String desc;
+
+    private static Map<Integer, String> descMap = new HashMap<>();
+    static {
+        for (UserGender gender : UserGender.values()) {
+            descMap.put(gender.code, gender.desc);
+        }
+    }
+
+    UserGender(Integer code, String desc) {
+        this.code = code;
+        this.desc = desc;
+    }
+
+    // TODO 第一次调用时会初始化 descMap
+    public static String getDescByCode(int code) {
+        return descMap.get(code);
+    }
+}

+ 32 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/AccountLoginDto.java

@@ -0,0 +1,32 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.hibernate.validator.constraints.Length;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+
+/**
+ * 用户登录提交的数据
+ *
+ * @author reghao
+ * @date 2021-11-17 16:36:05
+ */
+@Setter
+@Getter
+public class AccountLoginDto implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @NotBlank
+    private String principal;
+    @NotBlank
+    private String credential;
+    @Length(min = 5, max = 5, message = "图形验证码应是 5 个字符")
+    private String captchaCode;
+    private Boolean rememberMe;
+
+    public AccountLoginDto() {
+        this.rememberMe = false;
+    }
+}

+ 21 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/AccountProfile.java

@@ -0,0 +1,21 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2021-07-12 16:06:11
+ */
+@Data
+public class AccountProfile implements Serializable {
+    private static final long serialVersionUID = 1L;
+    private Integer userId;
+    @NotBlank(message = "显示名不能为空白字符串")
+    private String screenName;
+    private String mobile;
+    private String email;
+    private Integer gender;
+}

+ 20 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/AccountRole.java

@@ -0,0 +1,20 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import cn.reghao.devops.web.admin.account.model.po.Role;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+
+/**
+ * @author reghao
+ * @date 2021-07-14 09:37:16
+ */
+@Data
+public class AccountRole {
+    @NotNull
+    private Integer userId;
+    //@Size(min = 1, max = 1, message = "用户有且只能有 1 个角色")
+    //private Set<Role> roles;
+    @NotNull
+    private Role role;
+}

+ 31 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/CreateAccountDto.java

@@ -0,0 +1,31 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+/**
+ * @author reghao
+ * @date 2023-10-23 17:38:19
+ */
+@Setter
+@Getter
+public class CreateAccountDto {
+    @NotBlank(message = "必须指定登录名")
+    private String username;
+    @NotBlank(message = "必须指定用户名")
+    private String screenName;
+    @NotBlank(message = "必须指定登录密码")
+    private String password;
+    @NotNull
+    private Integer roleId;
+
+    public CreateAccountDto(String username, String password, int roleId) {
+        this.username = username;
+        this.screenName = username;
+        this.password = password;
+        this.roleId = roleId;
+    }
+}

+ 28 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/MenuDto.java

@@ -0,0 +1,28 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+/**
+ * @author reghao
+ * @date 2021-07-15 14:06:13
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class MenuDto {
+    @NotNull(message = "菜单 ID 不能为 NULL")
+    private Integer menuId;
+    // 在同一个 pid 组内的位置,作为排序使用
+    private Integer pos;
+    @NotBlank(message = "菜单名不能为空白字符串")
+    private String name;
+    @NotBlank(message = "URL 地址不能为空白字符串")
+    private String url;
+    @NotBlank(message = "icon 不能为空白字符串")
+    private String icon;
+}

+ 20 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/RoleDto.java

@@ -0,0 +1,20 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.validator.constraints.Length;
+
+import javax.validation.constraints.Pattern;
+
+/**
+ * @author reghao
+ * @date 2021-07-14 14:14:07
+ */
+@NoArgsConstructor
+@Data
+public class RoleDto {
+    @Pattern(regexp = "^\\w+$", message = "角色名只能是英文字符")
+    private String name;
+    @Length(max = 100, message = "角色描述的长度不超过 100 个中文字符")
+    private String description;
+}

+ 19 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/RsaPubkey.java

@@ -0,0 +1,19 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2024-01-29 13:49:33
+ */
+@NoArgsConstructor
+@AllArgsConstructor
+@Setter
+@Getter
+public class RsaPubkey {
+    private String pubkey;
+    private String r;
+}

+ 19 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/UpdatePasswordDto.java

@@ -0,0 +1,19 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-02-22 11:04:24
+ */
+@Setter
+@Getter
+public class UpdatePasswordDto implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String original;
+    private String password;
+}

+ 24 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/dto/UserUpdateDTO.java

@@ -0,0 +1,24 @@
+package cn.reghao.devops.web.admin.account.model.dto;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2021-07-12 16:06:11
+ */
+@Data
+public class UserUpdateDTO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @NotNull(message = "用户 ID 不能为 NULL")
+    private Integer userId;
+    @NotBlank(message = "用户名不能为空白字符串")
+    private String screenName;
+    // TODO 验证邮箱和手机号码是否有效
+    private String mobile;
+    private String email;
+}

+ 51 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/po/Menu.java

@@ -0,0 +1,51 @@
+package cn.reghao.devops.web.admin.account.model.po;
+
+import cn.reghao.devops.web.admin.util.BaseEntity;
+import lombok.*;
+
+import javax.persistence.*;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.util.*;
+
+/**
+ * Layui 菜单, 作为 RBAC 中的 resource
+ *
+ * @author reghao
+ * @date 2021-04-04 22:42:14
+ */
+@Setter
+@Getter
+@Table(name = "sys_menu")
+@Entity
+public class Menu extends BaseEntity {
+    // Menu 类型
+    @NotBlank(message = "菜单类型不能为空白字符串")
+    private String type;
+    @NotBlank(message = "菜单名不能为空白字符串")
+    private String name;
+    @NotBlank(message = "URL 地址不能为空白字符串")
+    private String url;
+    private String icon;
+    // 父级菜单 ID
+    @NotNull(message = "父级菜单不能为 NULL")
+    private Integer pid;
+    // 在同一个 pid 组内的位置,作为排序使用
+    private Integer pos;
+    private Boolean enabled;
+    @ManyToMany(mappedBy = "menus")
+    private Set<Role> roles;
+    // Menu 拥有的所有子 Menu(按排序顺序, 不持久化)
+    @Transient
+    private Map<Integer, Menu> children;
+
+    public Menu(int id, String name) {
+        this.id = id;
+        this.name = name;
+    }
+
+    public Menu() {
+        this.enabled = true;
+        this.icon = "layui-icon layui-icon-app";
+    }
+}

+ 43 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/po/Role.java

@@ -0,0 +1,43 @@
+package cn.reghao.devops.web.admin.account.model.po;
+
+import cn.reghao.devops.web.admin.util.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import org.hibernate.validator.constraints.Length;
+
+import javax.persistence.*;
+import javax.validation.constraints.Pattern;
+import java.util.Set;
+
+/**
+ * @author reghao
+ * @date 2019/03/14 21:46:13
+ */
+@NoArgsConstructor
+@Data
+@EqualsAndHashCode(callSuper = false, exclude = {"description", "menus"})
+@ToString(exclude = {"menus"})
+@Table(name = "sys_role")
+@Entity
+public class Role extends BaseEntity {
+    // TODO 匹配小写英文字符报错
+    // @Pattern(regexp = "^[a-z]+$", message = "角色名只能是英文字符")
+    @Pattern(regexp = "^\\w+$", message = "角色名只能是英文字符")
+    @Column(unique = true, nullable = false)
+    private String name;
+    @Length(max = 100, message = "角色描述的长度不超过 100 个中文字符")
+    private String description;
+    // Role 端维护 Role 和 Menu 之间的关系
+    @ManyToMany(fetch = FetchType.EAGER)
+    @JoinTable(name = "sys_role_menu",
+            joinColumns = @JoinColumn(name = "role_id"),
+            inverseJoinColumns = @JoinColumn(name = "menu_id"))
+    private Set<Menu> menus;
+
+    public Role(String name, String description) {
+        this.name = name;
+        this.description = description;
+    }
+}

+ 139 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/po/User.java

@@ -0,0 +1,139 @@
+package cn.reghao.devops.web.admin.account.model.po;
+
+import cn.reghao.devops.web.admin.util.BaseEntity;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.*;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import javax.persistence.*;
+import javax.validation.constraints.NotBlank;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 用户
+ *
+ * @author reghao
+ * @date 2019/03/14 19:12:48
+ */
+@NoArgsConstructor
+@Getter
+@Setter
+@Table(name = "sys_user")
+@Entity
+public class User extends BaseEntity implements UserDetails {
+    // 用户名和密码
+    @Column(nullable = false, unique = true)
+    @NotBlank(message = "用户名不能为空白字符串")
+    private String username;
+    @Column(nullable = false)
+    @JsonIgnore
+    @NotBlank(message = "密码不能为空白字符串")
+    private String encodedPassword;
+    @Column(nullable = false)
+    private String salt;
+    private LocalDateTime createAt;
+
+    /*@ElementCollection(fetch = FetchType.EAGER)
+    @CollectionTable(name = "sys_user_role")
+    private Set<String> role;*/
+    @OneToOne
+    private Role role;
+    private Boolean enabled = true;
+    private Boolean locked = false;
+
+    @NotBlank(message = "用户名不能为空白字符串")
+    private String screenName;
+    private String avatarUrl;
+    private String mobile;
+    private String email;
+    private Integer gender;
+
+    public User(String username, String encodedPassword, String salt, Role role) {
+        this.username = username;
+        this.encodedPassword = encodedPassword;
+        this.salt = salt;
+        this.createAt = LocalDateTime.now();
+        this.role = role;
+        this.enabled = true;
+        this.locked = false;
+        this.screenName = username;
+        this.gender = 3;
+        this.avatarUrl = "/imgs/avatar.jpg";
+    }
+
+    @Override
+    public String getUsername() {
+        return username;
+    }
+
+    @Override
+    public String getPassword() {
+        return encodedPassword;
+    }
+
+    /**
+     * 帐号是否未过期
+     *
+     * @param
+     * @return
+     * @date 2019-05-29 下午2:05
+     */
+    @Override
+    public boolean isAccountNonExpired() {
+        return true;
+    }
+
+    /**
+     * 密码是否未过期
+     *
+     * @param
+     * @return
+     * @date 2019-05-29 下午2:05
+     */
+    @Override
+    public boolean isCredentialsNonExpired() {
+        return true;
+    }
+
+    /**
+     * 帐号是否可用
+     *
+     * @param
+     * @return
+     * @date 2019-05-29 下午2:05
+     */
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    /**
+     * 帐号是否锁定
+     *
+     * @param
+     * @return
+     * @date 2019-05-29 下午2:05
+     */
+    @Override
+    public boolean isAccountNonLocked() {
+        return locked;
+    }
+
+    /**
+     * 权限列表
+     *
+     * @param
+     * @return
+     * @date 2019-05-29 下午2:22
+     */
+    @Override
+    public Collection<? extends GrantedAuthority> getAuthorities() {
+        //return role.stream().map(UserAuthority::new).collect(Collectors.toSet());
+        UserAuthority userAuthority = new UserAuthority(role.getName());
+        return Set.of(userAuthority);
+    }
+}

+ 49 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/po/UserAuthority.java

@@ -0,0 +1,49 @@
+package cn.reghao.devops.web.admin.account.model.po;
+
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.SpringSecurityCoreVersion;
+import org.springframework.util.Assert;
+
+/**
+ * SimpleGrantedAuthority 的重写, 仅用于 SpringSecurity
+ *
+ * @author reghao
+ * @date 2020-06-24 14:44:25
+ */
+public class UserAuthority implements GrantedAuthority {
+    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
+    private final String role;
+
+    public UserAuthority(String role) {
+        Assert.hasText(role, "A granted authority textual representation is required");
+        this.role = role;
+    }
+
+    @Override
+    public String getAuthority() {
+        return role;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+
+        if (obj instanceof UserAuthority) {
+            return role.equals(((UserAuthority) obj).role);
+        }
+
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return this.role.hashCode();
+    }
+
+    @Override
+    public String toString() {
+        return this.role;
+    }
+}

+ 28 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/vo/MenuTree.java

@@ -0,0 +1,28 @@
+package cn.reghao.devops.web.admin.account.model.vo;
+
+import cn.reghao.devops.web.admin.account.model.po.Menu;
+import lombok.Data;
+
+/**
+ * @author reghao
+ * @date 2021-07-15 14:06:13
+ */
+@Data
+public class MenuTree {
+    private int id;
+    private int pid;
+    private String name;
+    private String url;
+    private String type;
+    // 授予了访问权限
+    private boolean granted;
+
+    public MenuTree(Menu menu) {
+        this.id = menu.getId();
+        this.pid = menu.getPid();
+        this.name = menu.getName();
+        this.url = menu.getUrl();
+        this.type = menu.getType();
+        this.granted = false;
+    }
+}

+ 39 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/vo/RoleVO.java

@@ -0,0 +1,39 @@
+package cn.reghao.devops.web.admin.account.model.vo;
+
+import cn.reghao.devops.web.admin.account.model.po.Role;
+import cn.reghao.jutil.jdk.converter.DateTimeConverter;
+import lombok.Data;
+
+import java.util.Locale;
+
+/**
+ * @author reghao
+ * @date 2021-07-14 14:14:07
+ */
+@Data
+public class RoleVO {
+    private Integer id;
+    private String name;
+    private String description;
+    private String createTime;
+    private String updateTime;
+    private int total;
+
+    public RoleVO(Role role) {
+        this.id = role.getId();
+        this.name = role.getName().split("ROLE_")[1].toLowerCase(Locale.ROOT);
+        this.description = role.getDescription();
+        this.createTime = DateTimeConverter.format(role.getCreateTime());
+        this.updateTime = DateTimeConverter.format(role.getUpdateTime());
+        this.total = 0;
+    }
+
+    public RoleVO(Role role, int total) {
+        this.id = role.getId();
+        this.name = role.getName().split("ROLE_")[1].toLowerCase(Locale.ROOT);
+        this.description = role.getDescription();
+        this.createTime = DateTimeConverter.format(role.getCreateTime());
+        this.updateTime = DateTimeConverter.format(role.getUpdateTime());
+        this.total = total;
+    }
+}

+ 36 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/model/vo/UserVO.java

@@ -0,0 +1,36 @@
+package cn.reghao.devops.web.admin.account.model.vo;
+
+import cn.reghao.devops.web.admin.account.model.constant.UserGender;
+import cn.reghao.devops.common.util.NotAvailable;
+import cn.reghao.devops.web.admin.account.model.po.User;
+import cn.reghao.jutil.jdk.converter.DateTimeConverter;
+import lombok.Data;
+
+/**
+ * @author reghao
+ * @date 2021-07-14 10:28:47
+ */
+@Data
+public class UserVO {
+    private Integer userId;
+    private String username;
+    private String screenName;
+    private String gender;
+    private String mobile;
+    private String email;
+    private String createTime;
+    private String lastAccess;
+    private String status;
+
+    public UserVO(User user) {
+        this.userId = user.getId();
+        this.username = user.getUsername();
+        this.screenName = user.getScreenName();
+        this.gender = UserGender.getDescByCode(user.getGender());
+        this.mobile = user.getMobile();
+        this.email = user.getEmail();
+        this.createTime = DateTimeConverter.format(user.getCreateTime());
+        this.status = user.getEnabled() ? "启用" : "禁用";
+        this.lastAccess = NotAvailable.na.getDesc();
+    }
+}

+ 28 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/ExceptionAuthenticationEntryPoint.java

@@ -0,0 +1,28 @@
+package cn.reghao.devops.web.admin.account.security;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 在 WebSecurityConfig 中配置的需要认证的接口没有获取到请求中的认证信息时会转到此处进行处理
+ *
+ * @author reghao
+ * @date 2023-07-28 16:43:59
+ */
+@Slf4j
+public class ExceptionAuthenticationEntryPoint implements AuthenticationEntryPoint {
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
+            throws IOException, ServletException {
+        String uri = request.getRequestURI();
+        String errMsg = exception.getMessage();
+        log.error("请求 {} 接口认证失败: {}, 重定向到登录页面", uri, errMsg);
+        response.sendRedirect("/login");
+    }
+}

+ 199 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/WebSecurityConfig.java

@@ -0,0 +1,199 @@
+package cn.reghao.devops.web.admin.account.security;
+
+import cn.reghao.devops.web.admin.account.security.filter.LoginRedirectFilter;
+import cn.reghao.devops.web.admin.account.security.form.AccountAuthFilter;
+import cn.reghao.devops.web.admin.account.security.form.AccountAuthProvider;
+import cn.reghao.devops.web.admin.account.service.AccountAuthService;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
+import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.builders.WebSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+import org.springframework.security.web.authentication.session.*;
+import org.springframework.security.web.context.SecurityContextPersistenceFilter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Web 应用安全配置
+ *
+ * @author reghao
+ * @date 2019-05-20 10:41:10
+ */
+@Configuration
+@EnableWebSecurity
+@EnableGlobalMethodSecurity(prePostEnabled = true) // 调用方法时检查权限
+public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
+    private final String loginPage = "/login";
+    private final String loginApi = "/login";
+    private final String logoutApi = "/logout";
+
+    private final AccountAuthProvider userAuthProvider;
+    private final AccountAuthService accountAuthService;
+    private final AuthenticationSuccessHandler successHandler;
+    private final AuthenticationFailureHandler failureHandler;
+    private final LogoutHandler logoutHandler;
+    private final LogoutSuccessHandler logoutSuccessHandler;
+    private final SessionRegistry sessionRegistry;
+
+    public WebSecurityConfig(AccountAuthProvider userAuthProvider, AccountAuthService accountAuthService,
+                             AuthenticationSuccessHandler successHandler, AuthenticationFailureHandler failureHandler,
+                             LogoutHandler logoutHandler, LogoutSuccessHandler logoutSuccessHandler,
+                             SessionRegistry sessionRegistry) {
+        this.userAuthProvider = userAuthProvider;
+        this.accountAuthService = accountAuthService;
+        this.successHandler = successHandler;
+        this.failureHandler = failureHandler;
+        this.logoutHandler = logoutHandler;
+        this.logoutSuccessHandler = logoutSuccessHandler;
+        this.sessionRegistry = sessionRegistry;
+    }
+
+    /**
+     * 放行静态资源
+     *
+     * @param
+     * @return
+     * @date 2021-04-04 下午9:06
+     */
+    @Override
+    public void configure(WebSecurity web) {
+        web.ignoring()
+                .antMatchers("/ws/**")
+                .antMatchers("/css/**")
+                .antMatchers("/imgs/**")
+                .antMatchers("/js/**")
+                .antMatchers("/lib/**");
+    }
+
+    @Override
+    protected void configure(HttpSecurity http) throws Exception {
+        http.authorizeRequests()
+                .antMatchers("/login").permitAll()
+                // 放行匹配前缀的请求
+                .antMatchers("/api/account/code/**").permitAll()
+                .anyRequest().authenticated();
+
+        // 配置 FilterChainProxy 过滤器链
+        http.addFilterAfter(new LoginRedirectFilter(), SecurityContextPersistenceFilter.class);
+        http.addFilterBefore(accountAuthFilter(sessionRegistry), UsernamePasswordAuthenticationFilter.class);
+
+        // 禁用 UsernamePasswordAuthenticationFilter, 使用自定义的 AccountAuthFilter
+        http.formLogin().disable();
+
+        // 配置 LogoutFilter
+        http.logout()
+                .logoutUrl(logoutApi)
+                .addLogoutHandler(logoutHandler)
+                .logoutSuccessHandler(logoutSuccessHandler);
+
+        // 配置 ExceptionTranslationFilter, 登录认证接口失败时的处理, 不会重定向到 loginPage
+        http.exceptionHandling()
+                .authenticationEntryPoint(new ExceptionAuthenticationEntryPoint());
+
+        // 配置 SessionManagementFilter 和 ConcurrentSessionFilter
+        http.sessionManagement()
+                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
+                //.sessionAuthenticationStrategy(new MySessionAuthenticationStrategy(sessionRegistry()));
+                .maximumSessions(1)
+                .expiredUrl(loginPage);
+
+        // 配置 RememberMeAuthenticationFilter, 禁用 RememberMeAuthenticationFilter
+        http.rememberMe().disable();
+                /*.key("DExNzAyNTQ2Nzo3NDI3MTNhYmM5MGE5")
+                .rememberMeParameter("rememberMe");*/
+
+        // 配置 CorsFilter, 禁用 CorsFilter
+        http.cors().disable();
+
+        // 配置 CsrfFilter, 禁用 CsrfFilter
+        http.csrf().disable();
+
+        // 配置 HeaderWriterFilter, 禁用 HeaderWriterFilter
+        http.headers().disable();
+    }
+
+    /**
+     * 配置认证管理器
+     *
+     * @param
+     * @return
+     * @date 2021-07-25 下午2:28
+     */
+    @Bean
+    @Override
+    public AuthenticationManager authenticationManagerBean() throws Exception {
+        return super.authenticationManagerBean();
+    }
+
+    /**
+     * 配置认证使用的 provider
+     *
+     * @param
+     * @return
+     * @date 2021-07-25 下午2:31
+     */
+    @Override
+    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
+        auth.authenticationProvider(userAuthProvider);
+    }
+
+    /**
+     * 配置账号密码登录 filter
+     *
+     * @param
+     * @return
+     * @date 2022-07-06 上午9:54
+     */
+    @Bean
+    public AccountAuthFilter accountAuthFilter(SessionRegistry sessionRegistry) throws Exception {
+        AccountAuthFilter filter = new AccountAuthFilter(loginApi, "POST", accountAuthService);
+        filter.setAuthenticationManager(super.authenticationManager());
+        filter.setAuthenticationSuccessHandler(successHandler);
+        filter.setAuthenticationFailureHandler(failureHandler);
+        filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy(sessionRegistry));
+        return filter;
+    }
+
+    public CompositeSessionAuthenticationStrategy sessionAuthenticationStrategy(SessionRegistry sessionRegistry){
+        ConcurrentSessionControlAuthenticationStrategy controlAuthenticationStrategy =
+                new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
+        // 设置帐号同时登录的最大数量
+        controlAuthenticationStrategy.setMaximumSessions(1);
+        //controlAuthenticationStrategy.setExceptionIfMaximumExceeded(true);
+
+        List<SessionAuthenticationStrategy> authenticationStrategies = new ArrayList<>();
+        authenticationStrategies.add(controlAuthenticationStrategy);
+        authenticationStrategies.add(new SessionFixationProtectionStrategy());
+        authenticationStrategies.add(new RegisterSessionAuthenticationStrategy(sessionRegistry));
+
+        return new CompositeSessionAuthenticationStrategy(authenticationStrategies);
+    }
+
+    /**
+     * 角色继承
+     * ADMIN 可以访问 USER 的权限,反之不可
+     *
+     * @date 2019-07-05 上午11:18
+     */
+    @Bean
+    public RoleHierarchy roleHierarchy() {
+        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
+        roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
+        return roleHierarchy;
+    }
+}

+ 36 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/encoder/Md5PasswordEncoder.java

@@ -0,0 +1,36 @@
+package cn.reghao.devops.web.admin.account.security.encoder;
+
+import cn.reghao.jutil.jdk.security.Md5Cryptor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * @author reghao
+ * @date 2019-03-26 14:46:57
+ */
+@Component
+public class Md5PasswordEncoder implements PasswordEncoder {
+    private final Md5Cryptor md5Cryptor;
+
+    public Md5PasswordEncoder() throws NoSuchAlgorithmException {
+        this.md5Cryptor = new Md5Cryptor();
+    }
+
+    @Override
+    public String encode(CharSequence rawPassword) {
+        // rawPassword 带盐值
+        return md5Cryptor.encrypt(rawPassword.toString());
+    }
+
+    private byte[] encode(CharSequence rawPassword, byte[] salt) {
+        return null;
+    }
+
+    @Override
+    public boolean matches(CharSequence rawPassword, String encodedPassword) {
+        String password = md5Cryptor.encrypt(rawPassword.toString());
+        return encodedPassword.equals(password);
+    }
+}

+ 13 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/exceptioin/AccountLoginException.java

@@ -0,0 +1,13 @@
+package cn.reghao.devops.web.admin.account.security.exceptioin;
+
+import org.springframework.security.core.AuthenticationException;
+
+/**
+ * @author reghao
+ * @date 2020-10-09 22:26:09
+ */
+public class AccountLoginException extends AuthenticationException {
+    public AccountLoginException(String msg) {
+        super(msg);
+    }
+}

+ 33 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/filter/LoginRedirectFilter.java

@@ -0,0 +1,33 @@
+package cn.reghao.devops.web.admin.account.security.filter;
+
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import javax.servlet.*;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 已登录状态下,访问 /login 页面自动跳转到 /
+ *
+ * @author reghao
+ * @date 2019-06-04 23:06:50
+ */
+public class LoginRedirectFilter implements Filter {
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+            throws IOException, ServletException {
+        HttpServletRequest httpRequest = (HttpServletRequest) request;
+        HttpServletResponse httpResponse = (HttpServletResponse) response;
+
+        SecurityContext securityContext = SecurityContextHolder.getContext();
+        String url = httpRequest.getRequestURI().substring(httpRequest.getContextPath().length());
+        if (securityContext != null && securityContext.getAuthentication() != null && "/login".equals(url)) {
+            // 将登录页面重定向到主页
+            httpResponse.sendRedirect("/");
+        } else {
+            chain.doFilter(request, response);
+        }
+    }
+}

+ 48 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/form/AccountAuthFilter.java

@@ -0,0 +1,48 @@
+package cn.reghao.devops.web.admin.account.security.form;
+
+import cn.reghao.devops.web.admin.account.model.dto.AccountLoginDto;
+import cn.reghao.devops.web.admin.account.service.AccountAuthService;
+import cn.reghao.jutil.web.ServletUtil;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * 账号密码登录 filter
+ * 替换 UsernamePasswordAuthenticationFilter,用于认证用户,匹配指定 URL 才会进行处理
+ *
+ * @author reghao
+ * @date 2022-07-06 15:55:59
+ */
+public class AccountAuthFilter extends AbstractAuthenticationProcessingFilter {
+    private final AccountAuthService accountAuthService;
+
+    public AccountAuthFilter(String authUrl, String httpMethod, AccountAuthService accountAuthService) {
+        super(new AntPathRequestMatcher(authUrl, httpMethod));
+        this.accountAuthService = accountAuthService;
+    }
+
+    /**
+     * 构造 Authentication 对象
+     * 参照 UsernamePasswordAuthenticationFilter
+     *
+     * @param
+     * @return
+     * @date 2020-05-06 上午11:16
+     */
+    @Override
+    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
+            throws AuthenticationException, IOException, ServletException {
+        // json payload 中的 username 和 password 参数
+        AccountLoginDto accountLoginDto = (AccountLoginDto) ServletUtil.getBody(request, AccountLoginDto.class);
+        AccountAuthToken preAuthToken = accountAuthService.getPreAuthentication(accountLoginDto);
+        // 调用 UserAuthProvider.authenticate()
+        return this.getAuthenticationManager().authenticate(preAuthToken);
+    }
+}

+ 42 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/form/AccountAuthProvider.java

@@ -0,0 +1,42 @@
+package cn.reghao.devops.web.admin.account.security.form;
+
+import cn.reghao.devops.web.admin.account.service.AccountAuthService;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.stereotype.Component;
+
+/**
+ * 认证用户名/密码登录的 provider
+ *
+ * @author reghao
+ * @date 2019-04-09 09:07:40
+ */
+@Component
+public class AccountAuthProvider implements AuthenticationProvider {
+    private final AccountAuthService accountAuthService;
+
+    public AccountAuthProvider(AccountAuthService accountAuthService) {
+        this.accountAuthService = accountAuthService;
+    }
+
+    /**
+     * 对 Authentication 对象进行认证
+     * 参照 DaoAuthenticationProvider
+     *
+     * @param
+     * @return
+     * @date 2020-05-06 上午11:16
+     */
+    @Override
+    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+        AccountAuthToken preAuthToken = (AccountAuthToken) authentication;
+        AccountAuthToken authToken = accountAuthService.authByPassword(preAuthToken);
+        return authToken;
+    }
+
+    @Override
+    public boolean supports(Class<?> authentication) {
+        return (AccountAuthToken.class.isAssignableFrom(authentication));
+    }
+}

+ 126 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/form/AccountAuthToken.java

@@ -0,0 +1,126 @@
+package cn.reghao.devops.web.admin.account.security.form;
+
+import cn.reghao.devops.web.admin.account.model.po.User;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.SpringSecurityCoreVersion;
+
+import java.util.List;
+
+/**
+ * 参考 org.springframework.security.authentication.UsernamePasswordAuthenticationToken 实现
+ *
+ * @author reghao
+ * @date 2022-07-06 15:59:02
+ */
+public class AccountAuthToken extends AbstractAuthenticationToken {
+    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
+
+    // ~ Instance fields
+    // ================================================================================================
+    private final String loginId;
+    private final boolean rememberMe;
+    private final Integer userId;
+    private final Object principal;
+    private Object credentials;
+
+    // ~ Constructors
+    // ===================================================================================================
+
+    /**
+     * 等待认证时创建的 Authentication 对象
+     *
+     * This constructor can be safely used by any code that wishes to create a
+     * <code>PasswordAuthToken</code>, as the {@link #isAuthenticated()}
+     * will return <code>false</code>.
+     *
+     */
+    public AccountAuthToken(String loginId, Boolean rememberMe, Object principal, Object credentials) {
+        super(null);
+        super.setAuthenticated(false);
+        this.loginId = loginId;
+        this.rememberMe = rememberMe;
+        this.userId = null;
+        this.principal = principal;
+        this.credentials = credentials;
+    }
+
+    /**
+     * 认证通过时创建的 Authentication 对象
+     *
+     * This constructor should only be used by <code>AuthenticationManager</code> or
+     * <code>AuthenticationProvider</code> implementations that are satisfied with
+     * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
+     * authentication token.
+     *
+     * @param authToken
+     * @param user
+     */
+    public AccountAuthToken(AccountAuthToken authToken, User user) {
+        super(user.getAuthorities());
+        super.setAuthenticated(true); // must use super, as we override
+        super.setDetails(user);
+        this.loginId = authToken.getLoginId();
+        this.rememberMe = authToken.isRememberMe();
+        this.userId = user.getId();
+        this.principal = authToken.getPrincipal();
+        this.credentials = authToken.getCredentials();
+    }
+
+    /**
+     * 从 AccessToken 中恢复的已认证 Authentication 对象
+     *
+     * @param
+     * @return
+     * @date 2023-02-17 17:42:27
+     */
+    public AccountAuthToken(int plat, String loginId, int loginType, Object principal, List<GrantedAuthority> authorities) {
+        super(authorities);
+        super.setAuthenticated(true);
+        this.loginId = loginId;
+        this.rememberMe = false;
+        this.userId = Integer.parseInt((String) principal);
+        this.principal = principal;
+        this.credentials = null;
+    }
+
+    // ~ Methods
+    // ========================================================================================================
+    public String getLoginId() {
+        return loginId;
+    }
+
+    public boolean isRememberMe() {
+        return rememberMe;
+    }
+
+    public Integer getUserId() {
+        return this.userId;
+    }
+
+    @Override
+    public Object getCredentials() {
+        return this.credentials;
+    }
+
+    @Override
+    public Object getPrincipal() {
+        return this.principal;
+    }
+
+    /*@Override
+    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
+        if (isAuthenticated) {
+            throw new IllegalArgumentException(
+                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
+        }
+
+        super.setAuthenticated(false);
+    }*/
+
+    @Override
+    public void eraseCredentials() {
+        super.eraseCredentials();
+        credentials = null;
+    }
+}

+ 36 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/handler/AuthFailHandlerImpl.java

@@ -0,0 +1,36 @@
+package cn.reghao.devops.web.admin.account.security.handler;
+
+import cn.reghao.jutil.jdk.result.WebResult;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+
+/**
+ * 认证失败后的处理
+ *
+ * @author reghao
+ * @date 2019-04-09 00:02:34
+ */
+@Component
+public class AuthFailHandlerImpl implements AuthenticationFailureHandler {
+    @Override
+    public void onAuthenticationFailure(HttpServletRequest request,
+                                        HttpServletResponse response,
+                                        AuthenticationException exception) throws IOException, ServletException {
+        String errMsg = exception.getMessage();
+        String body = WebResult.failWithMsg(errMsg);
+        writeResponse(response, body);
+    }
+
+    private void writeResponse(HttpServletResponse response, String body) throws IOException {
+        response.setContentType("application/json; charset=utf-8");
+        PrintWriter printWriter = response.getWriter();
+        printWriter.write(body);
+    }
+}

+ 55 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/handler/AuthSuccessHandlerImpl.java

@@ -0,0 +1,55 @@
+package cn.reghao.devops.web.admin.account.security.handler;
+
+import cn.reghao.jutil.jdk.result.WebResult;
+import cn.reghao.jutil.web.ServletUtil;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.savedrequest.SavedRequest;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+
+/**
+ * 认证成功后跳转到登录前的地址
+ *
+ * @author reghao
+ * @date 2019-04-08 23:50:51
+ */
+@Component
+public class AuthSuccessHandlerImpl implements AuthenticationSuccessHandler {
+    @Override
+    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth)
+            throws IOException {
+        String redirectPath = getRedirectPath();
+        String body = WebResult.success(redirectPath);
+        writeResponse(response, body);
+    }
+
+    /**
+     * 认证成功后, 重定向到登录前的地址, 需要 session 的支持
+     *
+     * @param
+     * @return
+     * @date 2023-08-16 10:58:41
+     */
+    private String getRedirectPath() {
+        String redirectPath = "/";
+        // 获取 spring security 在 session 中存放的变量
+        SavedRequest savedRequest =
+                (SavedRequest) ServletUtil.getSession().getAttribute("SPRING_SECURITY_SAVED_REQUEST");
+        if (savedRequest != null) {
+            redirectPath = savedRequest.getRedirectUrl();
+        }
+
+        return redirectPath;
+    }
+
+    private void writeResponse(HttpServletResponse response, String body) throws IOException {
+        response.setContentType("application/json; charset=utf-8");
+        PrintWriter printWriter = response.getWriter();
+        printWriter.write(body);
+    }
+}

+ 27 - 0
web/src/main/java/cn/reghao/devops/web/admin/account/security/handler/LogoutHandlerImpl.java

@@ -0,0 +1,27 @@
+package cn.reghao.devops.web.admin.account.security.handler;
+
+import cn.reghao.devops.web.admin.account.service.AccountAuthService;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * @author reghao
+ * @date 2023-08-03 14:32:07
+ */
+@Component
+public class LogoutHandlerImpl implements LogoutHandler {
+    private final AccountAuthService accountAuthService;
+
+    public LogoutHandlerImpl(AccountAuthService accountAuthService) {
+        this.accountAuthService = accountAuthService;
+    }
+
+    @Override
+    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication auth) {
+        accountAuthService.logout();
+    }
+}

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff