commit 877335f73fc807b4d40544c74e2f393425555f12 Author: Tianqi Wang <1350217033@qq.com> Date: Mon Jun 23 16:50:28 2025 +0800 Initial commit: Jenkins CI/CD Demo Project with Spring Boot 3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b833418 --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +# Compiled class file +*.class + +# Log file +*.log +logs/ + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Eclipse +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# CDT- autotools +.autotools + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ + +# Annotation Processing +.apt_generated/ +.apt_generated_test/ + +# Scala IDE specific (Scala & Java IDE for Eclipse) +.cache-main +.scala_dependencies +.worksheet + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr +out/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +# VS Code +.vscode/ + +# Mac +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# SonarQube +.sonar/ + +# Docker +.dockerignore + +# Application specific +application-local.yml +application-local.properties + +# Test coverage +*.exec +jacoco.exec + +# Temporary files +*.tmp +*.temp diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..462686e --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.3/apache-maven-3.9.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9d091f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# 使用多阶段构建优化镜像大小 +FROM openjdk:17-jdk-slim as builder + +# 设置工作目录 +WORKDIR /app + +# 复制Maven配置文件 +COPY pom.xml . +COPY .mvn .mvn +COPY mvnw . + +# 下载依赖(利用Docker缓存层) +RUN ./mvnw dependency:go-offline -B + +# 复制源代码 +COPY src ./src + +# 构建应用 +RUN ./mvnw clean package -DskipTests + +# 运行时镜像 +FROM openjdk:17-jre-slim + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# 创建非root用户 +RUN groupadd -r spring && useradd -r -g spring spring + +# 创建应用目录 +WORKDIR /app + +# 从构建阶段复制jar文件 +COPY --from=builder /app/target/*.jar app.jar + +# 创建日志目录 +RUN mkdir -p /app/logs && chown -R spring:spring /app + +# 切换到非root用户 +USER spring + +# 暴露端口 +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/api/health || exit 1 + +# JVM调优参数 +ENV JAVA_OPTS="-server -Xms256m -Xmx512m -XX:+UseG1GC -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0" + +# 启动应用 +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar app.jar"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..5652df3 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,320 @@ +pipeline { + agent any + + options { + buildDiscarder(logRotator(numToKeepStr: '10')) + timeout(time: 30, unit: 'MINUTES') + timestamps() + } + + environment { + JAVA_HOME = '/usr/lib/jvm/java-17-openjdk' + MAVEN_HOME = '/opt/maven' + PATH = "${MAVEN_HOME}/bin:${JAVA_HOME}/bin:${env.PATH}" + + // Docker相关环境变量 + DOCKER_REGISTRY = 'your-registry.com' + IMAGE_NAME = 'jenkins-demo' + IMAGE_TAG = "${BUILD_NUMBER}" + + // SonarQube配置 + SONAR_HOST_URL = 'http://your-sonar-server:9000' + SONAR_PROJECT_KEY = 'jenkins-demo' + } + + tools { + maven 'Maven-3.9.3' + jdk 'JDK-17' + } + + stages { + stage('Checkout') { + steps { + echo '🔄 开始检出代码...' + checkout scm + + script { + env.GIT_COMMIT_SHORT = sh( + script: "git rev-parse --short HEAD", + returnStdout: true + ).trim() + } + + echo "📋 Git提交ID: ${env.GIT_COMMIT_SHORT}" + } + } + + stage('环境检查') { + steps { + echo '🔍 检查构建环境...' + sh ''' + echo "Java版本:" + java -version + echo "Maven版本:" + mvn -version + echo "Git版本:" + git --version + ''' + } + } + + stage('编译') { + steps { + echo '🔨 开始编译项目...' + sh 'mvn clean compile -DskipTests=true' + } + } + + stage('单元测试') { + steps { + echo '🧪 运行单元测试...' + sh 'mvn test' + } + post { + always { + // 发布测试结果 + publishTestResults testResultsPattern: 'target/surefire-reports/*.xml' + + // 发布代码覆盖率报告 + step([$class: 'JacocoPublisher', + execPattern: 'target/jacoco.exec', + classPattern: 'target/classes', + sourcePattern: 'src/main/java', + exclusionPattern: '**/*Test*.class' + ]) + } + } + } + + stage('代码质量扫描') { + steps { + echo '🔍 运行SonarQube代码扫描...' + script { + try { + withSonarQubeEnv('SonarQube') { + sh ''' + mvn sonar:sonar \ + -Dsonar.projectKey=${SONAR_PROJECT_KEY} \ + -Dsonar.host.url=${SONAR_HOST_URL} \ + -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + ''' + } + + // 等待质量门检查结果 + timeout(time: 5, unit: 'MINUTES') { + def qg = waitForQualityGate() + if (qg.status != 'OK') { + error "质量门检查失败: ${qg.status}" + } + } + } catch (Exception e) { + echo "⚠️ SonarQube扫描失败,继续构建流程: ${e.getMessage()}" + } + } + } + } + + stage('打包') { + steps { + echo '📦 开始打包应用程序...' + sh 'mvn package -DskipTests=true' + } + post { + success { + // 归档构建产物 + archiveArtifacts artifacts: 'target/*.jar', fingerprint: true + } + } + } + + stage('构建Docker镜像') { + steps { + echo '🐳 构建Docker镜像...' + script { + def image = docker.build("${IMAGE_NAME}:${IMAGE_TAG}") + + // 也创建latest标签 + sh "docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:latest" + + echo "✅ Docker镜像构建完成: ${IMAGE_NAME}:${IMAGE_TAG}" + } + } + } + + stage('推送Docker镜像') { + when { + anyOf { + branch 'main' + branch 'master' + branch 'develop' + } + } + steps { + echo '📤 推送Docker镜像到仓库...' + script { + docker.withRegistry("https://${DOCKER_REGISTRY}", 'docker-registry-credentials') { + def image = docker.image("${IMAGE_NAME}:${IMAGE_TAG}") + image.push() + image.push("latest") + } + echo "✅ Docker镜像推送完成" + } + } + } + + stage('部署到测试环境') { + when { + anyOf { + branch 'develop' + branch 'feature/*' + } + } + steps { + echo '🚀 部署到测试环境...' + script { + // 部署到测试服务器 + sshagent(['test-server-ssh']) { + sh ''' + ssh -o StrictHostKeyChecking=no user@test-server << EOF + # 停止现有容器 + docker stop jenkins-demo-test || true + docker rm jenkins-demo-test || true + + # 运行新容器 + docker run -d --name jenkins-demo-test \\ + -p 8080:8080 \\ + --restart unless-stopped \\ + ${IMAGE_NAME}:${IMAGE_TAG} + + echo "✅ 测试环境部署完成" +EOF + ''' + } + } + } + } + + stage('部署到生产环境') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + steps { + echo '🎯 部署到生产环境...' + script { + // 需要手动确认 + input message: '确认部署到生产环境?', ok: '部署', + parameters: [choice(name: 'DEPLOY_ENV', choices: ['prod'], description: '选择部署环境')] + + // 部署到生产服务器 + sshagent(['prod-server-ssh']) { + sh ''' + ssh -o StrictHostKeyChecking=no user@prod-server << EOF + # 备份当前版本 + docker tag ${IMAGE_NAME}:latest ${IMAGE_NAME}:backup-$(date +%Y%m%d-%H%M%S) || true + + # 停止现有容器 + docker stop jenkins-demo-prod || true + docker rm jenkins-demo-prod || true + + # 运行新容器 + docker run -d --name jenkins-demo-prod \\ + -p 80:8080 \\ + --restart unless-stopped \\ + -e SPRING_PROFILES_ACTIVE=prod \\ + ${IMAGE_NAME}:${IMAGE_TAG} + + echo "✅ 生产环境部署完成" +EOF + ''' + } + } + } + } + + stage('健康检查') { + steps { + echo '🏥 执行应用健康检查...' + script { + // 等待应用启动 + sleep(time: 30, unit: 'SECONDS') + + // 检查应用健康状态 + def healthCheckUrl = "http://localhost:8080/api/health" + def response = sh( + script: "curl -s -o /dev/null -w '%{http_code}' ${healthCheckUrl}", + returnStdout: true + ).trim() + + if (response == "200") { + echo "✅ 应用健康检查通过" + } else { + error "❌ 应用健康检查失败,HTTP状态码: ${response}" + } + } + } + } + } + + post { + always { + echo '🧹 清理工作空间...' + // 清理Docker镜像 + sh ''' + docker image prune -f + docker system prune -f + ''' + } + + success { + echo '✅ 流水线执行成功!' + // 发送成功通知 + script { + def message = """ +🎉 Jenkins构建成功! + +📋 项目: ${env.JOB_NAME} +🔢 构建号: ${env.BUILD_NUMBER} +🌿 分支: ${env.BRANCH_NAME} +📝 提交: ${env.GIT_COMMIT_SHORT} +⏱️ 持续时间: ${currentBuild.durationString} +🔗 构建链接: ${env.BUILD_URL} + """ + + // 可以集成钉钉、企业微信、邮件等通知 + echo message + } + } + + failure { + echo '❌ 流水线执行失败!' + // 发送失败通知 + script { + def message = """ +💥 Jenkins构建失败! + +📋 项目: ${env.JOB_NAME} +🔢 构建号: ${env.BUILD_NUMBER} +🌿 分支: ${env.BRANCH_NAME} +📝 提交: ${env.GIT_COMMIT_SHORT} +⏱️ 持续时间: ${currentBuild.durationString} +🔗 构建链接: ${env.BUILD_URL} +📄 查看日志: ${env.BUILD_URL}console + """ + + echo message + } + } + + unstable { + echo '⚠️ 构建不稳定,可能存在测试失败' + } + + cleanup { + // 清理工作空间 + cleanWs() + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b700c88 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ +# Jenkins Demo - Spring Boot 3 CI/CD 实践项目 + +这是一个用于实践Jenkins CI/CD流程的Spring Boot 3示例项目。项目包含完整的代码拉取、编译构建、单元测试、代码扫描、Docker打包和部署流程。 + +## 🚀 项目特性 + +- **Spring Boot 3.2.6** + **JDK 17** +- **RESTful API** - 提供用户管理相关接口 +- **单元测试** - 完整的JUnit 5测试覆盖 +- **代码质量** - 集成SonarQube代码扫描 +- **Docker支持** - 多阶段构建优化 +- **Jenkins Pipeline** - 完整的CI/CD流程 +- **健康检查** - 应用程序监控端点 + +## 📋 API接口 + +### 健康检查 +- `GET /api/health` - 应用健康状态 +- `GET /api/info` - 应用信息 +- `GET /api/welcome` - 欢迎信息 + +### 用户管理 +- `GET /api/users` - 获取所有用户 +- `GET /api/users/{id}` - 获取指定用户 +- `POST /api/users` - 创建新用户 +- `PUT /api/users/{id}` - 更新用户信息 +- `DELETE /api/users/{id}` - 删除用户 +- `POST /api/users/{id}/activate` - 激活用户 +- `POST /api/users/{id}/deactivate` - 停用用户 +- `GET /api/users/stats` - 用户统计信息 + +## 🛠️ 技术栈 + +- **后端框架**: Spring Boot 3.2.6 +- **Java版本**: JDK 17 +- **构建工具**: Maven 3.9+ +- **测试框架**: JUnit 5 + MockMvc +- **代码覆盖率**: JaCoCo +- **容器化**: Docker + Docker Compose +- **CI/CD**: Jenkins Pipeline + +## 🏃‍♂️ 快速开始 + +### 本地开发 + +1. **克隆项目** + ```bash + git clone + cd jenkins-demo + ``` + +2. **编译项目** + ```bash + mvn clean compile + ``` + +3. **运行测试** + ```bash + mvn test + ``` + +4. **启动应用** + ```bash + mvn spring-boot:run + ``` + +5. **访问应用** + - 应用地址: http://localhost:8080 + - 健康检查: http://localhost:8080/api/health + - 用户列表: http://localhost:8080/api/users + +### Docker运行 + +1. **构建镜像** + ```bash + docker build -t jenkins-demo:latest . + ``` + +2. **运行容器** + ```bash + docker run -d --name jenkins-demo -p 8080:8080 jenkins-demo:latest + ``` + +3. **使用Docker Compose** + ```bash + docker-compose up -d + ``` + +## 🔧 Maven命令 + +```bash +# 编译项目 +mvn clean compile + +# 运行测试 +mvn test + +# 生成代码覆盖率报告 +mvn test jacoco:report + +# 打包应用 +mvn clean package + +# 跳过测试打包 +mvn clean package -DskipTests + +# 代码质量扫描(需要配置SonarQube) +mvn sonar:sonar + +# 启动应用 +mvn spring-boot:run +``` + +## 🔍 代码质量 + +项目集成了以下代码质量工具: + +- **JaCoCo** - 代码覆盖率分析 +- **SonarQube** - 静态代码分析 +- **Maven Surefire** - 单元测试报告 + +生成测试报告: +```bash +mvn clean test jacoco:report +``` + +报告位置:`target/site/jacoco/index.html` + +## 🚀 Jenkins CI/CD流程 + +### Pipeline阶段 + +1. **代码检出** - 从Git仓库拉取代码 +2. **环境检查** - 验证Java、Maven等工具版本 +3. **编译** - 编译Java源代码 +4. **单元测试** - 运行JUnit测试并生成报告 +5. **代码扫描** - SonarQube静态代码分析 +6. **打包** - 生成可执行JAR文件 +7. **Docker构建** - 构建Docker镜像 +8. **镜像推送** - 推送到Docker Registry +9. **自动部署** - 部署到目标环境 +10. **健康检查** - 验证应用启动状态 + +### 环境要求 + +Jenkins服务器需要安装: +- JDK 17 +- Maven 3.9+ +- Docker +- Git +- SonarQube Scanner(可选) + +### 部署配置 + +修改`Jenkinsfile`中的以下配置: + +```groovy +environment { + DOCKER_REGISTRY = 'your-registry.com' // Docker仓库地址 + SONAR_HOST_URL = 'http://your-sonar-server:9000' // SonarQube服务器 + // ... 其他配置 +} +``` + +## 📦 项目结构 + +``` +jenkins-demo/ +├── src/ +│ ├── main/ +│ │ ├── java/com/jenkins/demo/ +│ │ │ ├── JenkinsDemoApplication.java # 应用主类 +│ │ │ ├── controller/ # REST控制器 +│ │ │ ├── service/ # 业务服务 +│ │ │ └── model/ # 数据模型 +│ │ └── resources/ +│ │ └── application.yml # 应用配置 +│ └── test/ # 测试代码 +├── Dockerfile # Docker镜像构建 +├── Jenkinsfile # Jenkins Pipeline +├── docker-compose.yml # Docker Compose配置 +├── pom.xml # Maven配置 +└── README.md # 项目文档 +``` + +## 🔗 API示例 + +### 创建用户 +```bash +curl -X POST http://localhost:8080/api/users \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "email": "test@example.com", + "name": "测试用户" + }' +``` + +### 获取所有用户 +```bash +curl http://localhost:8080/api/users +``` + +### 健康检查 +```bash +curl http://localhost:8080/api/health +``` + +## 📊 监控端点 + +Spring Boot Actuator提供了以下监控端点: + +- `/actuator/health` - 健康状态 +- `/actuator/info` - 应用信息 +- `/actuator/metrics` - 应用指标 +- `/actuator/prometheus` - Prometheus指标(如果启用) + +## 🤝 贡献指南 + +1. Fork项目 +2. 创建特性分支 (`git checkout -b feature/amazing-feature`) +3. 提交更改 (`git commit -m 'Add some amazing feature'`) +4. 推送到分支 (`git push origin feature/amazing-feature`) +5. 开启Pull Request + +## 📄 许可证 + +本项目使用MIT许可证。详情请参见[LICENSE](LICENSE)文件。 + +## 📞 联系方式 + +如有问题或建议,请通过以下方式联系: + +- 项目Issues: [GitHub Issues](https://github.com/your-username/jenkins-demo/issues) +- 邮箱: your-email@example.com + +--- + +🎉 **祝您使用愉快!** 如果这个项目对您有帮助,请给个⭐Star支持一下! diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..4641744 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,285 @@ +#!/bin/bash + +# Jenkins Demo 部署脚本 +# 用于生产环境部署 + +set -e + +# 配置变量 +APP_NAME="jenkins-demo" +APP_VERSION="1.0.0" +DOCKER_IMAGE="${APP_NAME}:${APP_VERSION}" +CONTAINER_NAME="${APP_NAME}-prod" +APP_PORT="80" +CONTAINER_PORT="8080" + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查Docker是否安装 +check_docker() { + if ! command -v docker &> /dev/null; then + log_error "Docker未安装,请先安装Docker" + exit 1 + fi + log_info "Docker检查通过" +} + +# 停止并移除现有容器 +stop_existing_container() { + if docker ps -a --format 'table {{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + log_info "停止现有容器: ${CONTAINER_NAME}" + docker stop ${CONTAINER_NAME} || true + docker rm ${CONTAINER_NAME} || true + log_success "现有容器已停止并移除" + else + log_info "未发现现有容器: ${CONTAINER_NAME}" + fi +} + +# 备份现有镜像 +backup_current_image() { + if docker images | grep -q "${APP_NAME}.*latest"; then + BACKUP_TAG="backup-$(date +%Y%m%d-%H%M%S)" + log_info "备份当前镜像为: ${APP_NAME}:${BACKUP_TAG}" + docker tag ${APP_NAME}:latest ${APP_NAME}:${BACKUP_TAG} + log_success "镜像备份完成" + fi +} + +# 拉取最新镜像 +pull_latest_image() { + log_info "拉取最新镜像: ${DOCKER_IMAGE}" + if docker pull ${DOCKER_IMAGE}; then + docker tag ${DOCKER_IMAGE} ${APP_NAME}:latest + log_success "镜像拉取完成" + else + log_error "镜像拉取失败" + exit 1 + fi +} + +# 启动新容器 +start_new_container() { + log_info "启动新容器: ${CONTAINER_NAME}" + + docker run -d \ + --name ${CONTAINER_NAME} \ + --restart unless-stopped \ + -p ${APP_PORT}:${CONTAINER_PORT} \ + -e SPRING_PROFILES_ACTIVE=prod \ + -e JAVA_OPTS="-Xms512m -Xmx1024m" \ + -v /var/log/${APP_NAME}:/app/logs \ + --health-cmd="curl -f http://localhost:${CONTAINER_PORT}/api/health || exit 1" \ + --health-interval=30s \ + --health-timeout=10s \ + --health-retries=3 \ + --health-start-period=60s \ + ${APP_NAME}:latest + + log_success "容器启动完成" +} + +# 健康检查 +health_check() { + log_info "执行健康检查..." + + local max_attempts=30 + local attempt=1 + + while [ $attempt -le $max_attempts ]; do + if curl -f -s http://localhost:${APP_PORT}/api/health > /dev/null; then + log_success "应用程序健康检查通过" + return 0 + fi + + log_info "健康检查失败,重试 (${attempt}/${max_attempts})" + sleep 10 + ((attempt++)) + done + + log_error "健康检查失败,应用程序可能未正常启动" + return 1 +} + +# 回滚函数 +rollback() { + log_warning "开始回滚操作..." + + # 停止失败的容器 + docker stop ${CONTAINER_NAME} || true + docker rm ${CONTAINER_NAME} || true + + # 查找最新的备份镜像 + BACKUP_IMAGE=$(docker images --format "table {{.Repository}}:{{.Tag}}" | grep "${APP_NAME}:backup-" | head -1) + + if [ -n "$BACKUP_IMAGE" ]; then + log_info "使用备份镜像回滚: ${BACKUP_IMAGE}" + docker tag ${BACKUP_IMAGE} ${APP_NAME}:latest + start_new_container + + if health_check; then + log_success "回滚成功" + else + log_error "回滚后健康检查仍然失败" + exit 1 + fi + else + log_error "未找到备份镜像,无法回滚" + exit 1 + fi +} + +# 清理旧镜像 +cleanup_old_images() { + log_info "清理旧的Docker镜像..." + + # 保留最近的5个备份镜像 + OLD_BACKUPS=$(docker images --format "table {{.Repository}}:{{.Tag}}" | grep "${APP_NAME}:backup-" | tail -n +6) + + if [ -n "$OLD_BACKUPS" ]; then + echo "$OLD_BACKUPS" | while read -r image; do + log_info "删除旧备份镜像: $image" + docker rmi "$image" || true + done + fi + + # 清理悬挂镜像 + docker image prune -f + + log_success "镜像清理完成" +} + +# 显示部署信息 +show_deployment_info() { + echo + log_success "=== 部署信息 ===" + echo "应用名称: ${APP_NAME}" + echo "应用版本: ${APP_VERSION}" + echo "容器名称: ${CONTAINER_NAME}" + echo "访问地址: http://localhost:${APP_PORT}" + echo "健康检查: http://localhost:${APP_PORT}/api/health" + echo "API文档: http://localhost:${APP_PORT}/api/users" + echo + log_success "=== 部署完成 ===" +} + +# 主函数 +main() { + log_info "开始部署 ${APP_NAME} v${APP_VERSION}" + + # 检查环境 + check_docker + + # 备份现有镜像 + backup_current_image + + # 停止现有容器 + stop_existing_container + + # 拉取新镜像 + pull_latest_image + + # 启动新容器 + start_new_container + + # 健康检查 + if health_check; then + # 清理旧镜像 + cleanup_old_images + + # 显示部署信息 + show_deployment_info + else + # 健康检查失败,执行回滚 + rollback + fi +} + +# 脚本使用说明 +usage() { + echo "用法: $0 [选项]" + echo + echo "选项:" + echo " deploy 执行部署(默认)" + echo " rollback 回滚到上一个版本" + echo " status 查看应用状态" + echo " logs 查看应用日志" + echo " stop 停止应用" + echo " help 显示帮助信息" + echo +} + +# 查看应用状态 +status() { + echo "=== 容器状态 ===" + docker ps -a --filter name=${CONTAINER_NAME} + echo + echo "=== 镜像列表 ===" + docker images | grep ${APP_NAME} +} + +# 查看应用日志 +logs() { + if docker ps --filter name=${CONTAINER_NAME} --format '{{.Names}}' | grep -q ${CONTAINER_NAME}; then + docker logs -f ${CONTAINER_NAME} + else + log_error "容器 ${CONTAINER_NAME} 未运行" + fi +} + +# 停止应用 +stop() { + log_info "停止应用..." + docker stop ${CONTAINER_NAME} || true + docker rm ${CONTAINER_NAME} || true + log_success "应用已停止" +} + +# 根据参数执行不同操作 +case "${1:-deploy}" in + deploy) + main + ;; + rollback) + rollback + ;; + status) + status + ;; + logs) + logs + ;; + stop) + stop + ;; + help) + usage + ;; + *) + log_error "未知参数: $1" + usage + exit 1 + ;; +esac diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14b4a79 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + jenkins-demo: + build: . + container_name: jenkins-demo-app + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=docker + - JAVA_OPTS=-Xms256m -Xmx512m + volumes: + - ./logs:/app/logs + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 可选:添加数据库服务(如果需要) + # postgres: + # image: postgres:15-alpine + # container_name: jenkins-demo-db + # environment: + # POSTGRES_DB: jenkins_demo + # POSTGRES_USER: jenkins_user + # POSTGRES_PASSWORD: jenkins_password + # volumes: + # - postgres_data:/var/lib/postgresql/data + # ports: + # - "5432:5432" + +# volumes: +# postgres_data: diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..293667d --- /dev/null +++ b/mvnw @@ -0,0 +1,233 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + printf '%s' "$(cd "$basedir"; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname $0)") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..95ba6f5 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,205 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7929d10 --- /dev/null +++ b/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.6 + + + + com.jenkins.demo + jenkins-demo + 1.0.0 + jenkins-demo + Jenkins CI/CD Demo Project with Spring Boot 3 + + + 17 + 17 + 17 + UTF-8 + jenkins-demo + Jenkins Demo + 1.0.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + + **/*Test.java + **/*Tests.java + + xml + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.10 + + + + prepare-agent + + + + report + test + + report + + + + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + 3.9.1.2184 + + + + + com.spotify + dockerfile-maven-plugin + 1.4.13 + + jenkins-demo + ${project.version} + + + + + diff --git a/src/main/java/com/jenkins/demo/JenkinsDemoApplication.java b/src/main/java/com/jenkins/demo/JenkinsDemoApplication.java new file mode 100644 index 0000000..4ddbb0d --- /dev/null +++ b/src/main/java/com/jenkins/demo/JenkinsDemoApplication.java @@ -0,0 +1,18 @@ +package com.jenkins.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Jenkins Demo Application Main Class + * + * @author Jenkins Demo Team + * @version 1.0.0 + */ +@SpringBootApplication +public class JenkinsDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(JenkinsDemoApplication.class, args); + } +} diff --git a/src/main/java/com/jenkins/demo/controller/HealthController.java b/src/main/java/com/jenkins/demo/controller/HealthController.java new file mode 100644 index 0000000..d528c56 --- /dev/null +++ b/src/main/java/com/jenkins/demo/controller/HealthController.java @@ -0,0 +1,58 @@ +package com.jenkins.demo.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Health Check Controller + * 提供应用程序健康检查接口 + */ +@RestController +@RequestMapping("/api") +public class HealthController { + + /** + * 健康检查接口 + * GET /api/health + */ + @GetMapping("/health") + public Map health() { + return Map.of( + "status", "UP", + "timestamp", LocalDateTime.now().toString(), + "application", "Jenkins Demo Application", + "version", "1.0.0" + ); + } + + /** + * 应用信息接口 + * GET /api/info + */ + @GetMapping("/info") + public Map info() { + return Map.of( + "name", "Jenkins Demo Application", + "description", "Spring Boot 3 应用程序用于Jenkins CI/CD流程演示", + "version", "1.0.0", + "java.version", System.getProperty("java.version"), + "spring.profiles.active", System.getProperty("spring.profiles.active", "default") + ); + } + + /** + * 简单的欢迎接口 + * GET /api/welcome + */ + @GetMapping("/welcome") + public Map welcome() { + return Map.of( + "message", "欢迎使用Jenkins Demo应用程序!", + "description", "这是一个用于演示Jenkins CI/CD流程的Spring Boot应用程序" + ); + } +} diff --git a/src/main/java/com/jenkins/demo/controller/UserController.java b/src/main/java/com/jenkins/demo/controller/UserController.java new file mode 100644 index 0000000..356cb6e --- /dev/null +++ b/src/main/java/com/jenkins/demo/controller/UserController.java @@ -0,0 +1,132 @@ +package com.jenkins.demo.controller; + +import com.jenkins.demo.model.User; +import com.jenkins.demo.service.UserService; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * User REST Controller + * 提供用户管理的REST API接口 + */ +@RestController +@RequestMapping("/api/users") +@CrossOrigin(origins = "*") +public class UserController { + + private final UserService userService; + + @Autowired + public UserController(UserService userService) { + this.userService = userService; + } + + /** + * 获取所有用户 + * GET /api/users + */ + @GetMapping + public ResponseEntity> getAllUsers() { + List users = userService.getAllUsers(); + return ResponseEntity.ok(users); + } + + /** + * 根据ID获取用户 + * GET /api/users/{id} + */ + @GetMapping("/{id}") + public ResponseEntity getUserById(@PathVariable Long id) { + Optional user = userService.getUserById(id); + return user.map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + /** + * 创建新用户 + * POST /api/users + */ + @PostMapping + public ResponseEntity createUser(@Valid @RequestBody User user) { + try { + User createdUser = userService.createUser(user); + return ResponseEntity.status(HttpStatus.CREATED).body(createdUser); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * 更新用户信息 + * PUT /api/users/{id} + */ + @PutMapping("/{id}") + public ResponseEntity updateUser(@PathVariable Long id, @Valid @RequestBody User user) { + try { + Optional updatedUser = userService.updateUser(id, user); + return updatedUser.map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * 删除用户 + * DELETE /api/users/{id} + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteUser(@PathVariable Long id) { + boolean deleted = userService.deleteUser(id); + return deleted ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + /** + * 激活用户 + * POST /api/users/{id}/activate + */ + @PostMapping("/{id}/activate") + public ResponseEntity> activateUser(@PathVariable Long id) { + boolean activated = userService.activateUser(id); + if (activated) { + return ResponseEntity.ok(Map.of("message", "用户已激活")); + } else { + return ResponseEntity.notFound().build(); + } + } + + /** + * 停用用户 + * POST /api/users/{id}/deactivate + */ + @PostMapping("/{id}/deactivate") + public ResponseEntity> deactivateUser(@PathVariable Long id) { + boolean deactivated = userService.deactivateUser(id); + if (deactivated) { + return ResponseEntity.ok(Map.of("message", "用户已停用")); + } else { + return ResponseEntity.notFound().build(); + } + } + + /** + * 获取用户统计信息 + * GET /api/users/stats + */ + @GetMapping("/stats") + public ResponseEntity> getUserStats() { + long totalUsers = userService.getUserCount(); + return ResponseEntity.ok(Map.of( + "totalUsers", totalUsers, + "timestamp", System.currentTimeMillis() + )); + } +} diff --git a/src/main/java/com/jenkins/demo/model/User.java b/src/main/java/com/jenkins/demo/model/User.java new file mode 100644 index 0000000..3f8a77f --- /dev/null +++ b/src/main/java/com/jenkins/demo/model/User.java @@ -0,0 +1,91 @@ +package com.jenkins.demo.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * User Entity Model + */ +public class User { + + private Long id; + + @NotBlank(message = "用户名不能为空") + @Size(min = 2, max = 50, message = "用户名长度必须在2-50个字符之间") + private String username; + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; + + @NotBlank(message = "姓名不能为空") + @Size(min = 1, max = 100, message = "姓名长度必须在1-100个字符之间") + private String name; + + private String status; + + // 默认构造函数 + public User() {} + + // 带参构造函数 + public User(Long id, String username, String email, String name, String status) { + this.id = id; + this.username = username; + this.email = email; + this.name = name; + this.status = status; + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", username='" + username + '\'' + + ", email='" + email + '\'' + + ", name='" + name + '\'' + + ", status='" + status + '\'' + + '}'; + } +} diff --git a/src/main/java/com/jenkins/demo/service/UserService.java b/src/main/java/com/jenkins/demo/service/UserService.java new file mode 100644 index 0000000..2828cff --- /dev/null +++ b/src/main/java/com/jenkins/demo/service/UserService.java @@ -0,0 +1,133 @@ +package com.jenkins.demo.service; + +import com.jenkins.demo.model.User; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * User Service Implementation + * 提供用户管理的业务逻辑 + */ +@Service +public class UserService { + + private final Map users = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + public UserService() { + // 初始化一些测试数据 + initializeTestData(); + } + + /** + * 获取所有用户 + */ + public List getAllUsers() { + return new ArrayList<>(users.values()); + } + + /** + * 根据ID获取用户 + */ + public Optional getUserById(Long id) { + return Optional.ofNullable(users.get(id)); + } + + /** + * 根据用户名查找用户 + */ + public Optional getUserByUsername(String username) { + return users.values().stream() + .filter(user -> user.getUsername().equals(username)) + .findFirst(); + } + + /** + * 创建新用户 + */ + public User createUser(User user) { + if (getUserByUsername(user.getUsername()).isPresent()) { + throw new IllegalArgumentException("用户名已存在: " + user.getUsername()); + } + + Long id = idGenerator.getAndIncrement(); + user.setId(id); + user.setStatus("ACTIVE"); + users.put(id, user); + return user; + } + + /** + * 更新用户信息 + */ + public Optional updateUser(Long id, User userUpdates) { + User existingUser = users.get(id); + if (existingUser == null) { + return Optional.empty(); + } + + // 检查用户名是否被其他用户使用 + if (!existingUser.getUsername().equals(userUpdates.getUsername())) { + Optional userWithSameUsername = getUserByUsername(userUpdates.getUsername()); + if (userWithSameUsername.isPresent() && !userWithSameUsername.get().getId().equals(id)) { + throw new IllegalArgumentException("用户名已存在: " + userUpdates.getUsername()); + } + } + + existingUser.setUsername(userUpdates.getUsername()); + existingUser.setEmail(userUpdates.getEmail()); + existingUser.setName(userUpdates.getName()); + + return Optional.of(existingUser); + } + + /** + * 删除用户 + */ + public boolean deleteUser(Long id) { + return users.remove(id) != null; + } + + /** + * 获取用户总数 + */ + public long getUserCount() { + return users.size(); + } + + /** + * 激活用户 + */ + public boolean activateUser(Long id) { + User user = users.get(id); + if (user != null) { + user.setStatus("ACTIVE"); + return true; + } + return false; + } + + /** + * 停用用户 + */ + public boolean deactivateUser(Long id) { + User user = users.get(id); + if (user != null) { + user.setStatus("INACTIVE"); + return true; + } + return false; + } + + /** + * 初始化测试数据 + */ + private void initializeTestData() { + createUser(new User(null, "admin", "admin@jenkins-demo.com", "系统管理员", null)); + createUser(new User(null, "user1", "user1@jenkins-demo.com", "张三", null)); + createUser(new User(null, "user2", "user2@jenkins-demo.com", "李四", null)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e72487a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,37 @@ +# Spring Boot Application Configuration +spring: + application: + name: jenkins-demo + + # Server Configuration + server: + port: 8080 + servlet: + context-path: / + + # Actuator Configuration + management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always + + # Logging Configuration + logging: + level: + com.jenkins.demo: INFO + org.springframework: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: logs/jenkins-demo.log + +# Custom Application Properties +app: + name: Jenkins Demo Application + version: 1.0.0 + description: Spring Boot 3 application for Jenkins CI/CD pipeline demonstration diff --git a/src/test/java/com/jenkins/demo/JenkinsDemoApplicationTests.java b/src/test/java/com/jenkins/demo/JenkinsDemoApplicationTests.java new file mode 100644 index 0000000..00b4057 --- /dev/null +++ b/src/test/java/com/jenkins/demo/JenkinsDemoApplicationTests.java @@ -0,0 +1,24 @@ +package com.jenkins.demo; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +/** + * Jenkins Demo Application Tests + */ +@SpringBootTest +@TestPropertySource(locations = "classpath:application-test.properties") +class JenkinsDemoApplicationTests { + + @Test + void contextLoads() { + // 测试Spring上下文是否能正常加载 + } + + @Test + void applicationStarts() { + // 测试应用程序是否能正常启动 + // 这个测试会验证所有的Spring Bean是否能正确创建和注入 + } +} diff --git a/src/test/java/com/jenkins/demo/controller/HealthControllerTest.java b/src/test/java/com/jenkins/demo/controller/HealthControllerTest.java new file mode 100644 index 0000000..f824fad --- /dev/null +++ b/src/test/java/com/jenkins/demo/controller/HealthControllerTest.java @@ -0,0 +1,54 @@ +package com.jenkins.demo.controller; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Health Controller Tests + */ +@WebMvcTest(HealthController.class) +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("GET /api/health - 应该返回健康状态") + void shouldReturnHealthStatus() throws Exception { + mockMvc.perform(get("/api/health")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.timestamp").exists()) + .andExpect(jsonPath("$.application").value("Jenkins Demo Application")) + .andExpect(jsonPath("$.version").value("1.0.0")); + } + + @Test + @DisplayName("GET /api/info - 应该返回应用信息") + void shouldReturnApplicationInfo() throws Exception { + mockMvc.perform(get("/api/info")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.name").value("Jenkins Demo Application")) + .andExpect(jsonPath("$.version").value("1.0.0")) + .andExpect(jsonPath("$.java.version").exists()); + } + + @Test + @DisplayName("GET /api/welcome - 应该返回欢迎信息") + void shouldReturnWelcomeMessage() throws Exception { + mockMvc.perform(get("/api/welcome")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value("欢迎使用Jenkins Demo应用程序!")) + .andExpect(jsonPath("$.description").exists()); + } +} diff --git a/src/test/java/com/jenkins/demo/controller/UserControllerTest.java b/src/test/java/com/jenkins/demo/controller/UserControllerTest.java new file mode 100644 index 0000000..4d90fef --- /dev/null +++ b/src/test/java/com/jenkins/demo/controller/UserControllerTest.java @@ -0,0 +1,179 @@ +package com.jenkins.demo.controller; + +import com.jenkins.demo.model.User; +import com.jenkins.demo.service.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * User Controller Integration Tests + */ +@WebMvcTest(UserController.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private UserService userService; + + @Autowired + private ObjectMapper objectMapper; + + private User testUser; + private List testUsers; + + @BeforeEach + void setUp() { + testUser = new User(1L, "testuser", "test@example.com", "测试用户", "ACTIVE"); + testUsers = Arrays.asList( + testUser, + new User(2L, "user2", "user2@example.com", "用户2", "ACTIVE") + ); + } + + @Test + @DisplayName("GET /api/users - 应该返回所有用户") + void shouldReturnAllUsers() throws Exception { + when(userService.getAllUsers()).thenReturn(testUsers); + + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].username").value("testuser")) + .andExpect(jsonPath("$[1].username").value("user2")); + + verify(userService).getAllUsers(); + } + + @Test + @DisplayName("GET /api/users/{id} - 应该返回指定用户") + void shouldReturnUserById() throws Exception { + when(userService.getUserById(1L)).thenReturn(Optional.of(testUser)); + + mockMvc.perform(get("/api/users/1")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.username").value("testuser")) + .andExpect(jsonPath("$.email").value("test@example.com")); + + verify(userService).getUserById(1L); + } + + @Test + @DisplayName("GET /api/users/{id} - 用户不存在时应该返回404") + void shouldReturn404WhenUserNotFound() throws Exception { + when(userService.getUserById(999L)).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/users/999")) + .andExpect(status().isNotFound()); + + verify(userService).getUserById(999L); + } + + @Test + @DisplayName("POST /api/users - 应该创建新用户") + void shouldCreateNewUser() throws Exception { + User newUser = new User(null, "newuser", "new@example.com", "新用户", null); + User createdUser = new User(3L, "newuser", "new@example.com", "新用户", "ACTIVE"); + + when(userService.createUser(any(User.class))).thenReturn(createdUser); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newUser))) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id").value(3)) + .andExpect(jsonPath("$.username").value("newuser")) + .andExpect(jsonPath("$.status").value("ACTIVE")); + + verify(userService).createUser(any(User.class)); + } + + @Test + @DisplayName("POST /api/users - 创建用户时验证失败应该返回400") + void shouldReturn400ForInvalidUserData() throws Exception { + User invalidUser = new User(null, "", "invalid-email", "", null); + + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidUser))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PUT /api/users/{id} - 应该更新用户") + void shouldUpdateUser() throws Exception { + User updateData = new User(null, "updateduser", "updated@example.com", "更新用户", null); + User updatedUser = new User(1L, "updateduser", "updated@example.com", "更新用户", "ACTIVE"); + + when(userService.updateUser(eq(1L), any(User.class))).thenReturn(Optional.of(updatedUser)); + + mockMvc.perform(put("/api/users/1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateData))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.username").value("updateduser")) + .andExpect(jsonPath("$.email").value("updated@example.com")); + + verify(userService).updateUser(eq(1L), any(User.class)); + } + + @Test + @DisplayName("DELETE /api/users/{id} - 应该删除用户") + void shouldDeleteUser() throws Exception { + when(userService.deleteUser(1L)).thenReturn(true); + + mockMvc.perform(delete("/api/users/1")) + .andExpect(status().isNoContent()); + + verify(userService).deleteUser(1L); + } + + @Test + @DisplayName("POST /api/users/{id}/activate - 应该激活用户") + void shouldActivateUser() throws Exception { + when(userService.activateUser(1L)).thenReturn(true); + + mockMvc.perform(post("/api/users/1/activate")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value("用户已激活")); + + verify(userService).activateUser(1L); + } + + @Test + @DisplayName("GET /api/users/stats - 应该返回用户统计信息") + void shouldReturnUserStats() throws Exception { + when(userService.getUserCount()).thenReturn(5L); + + mockMvc.perform(get("/api/users/stats")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.totalUsers").value(5)) + .andExpect(jsonPath("$.timestamp").exists()); + + verify(userService).getUserCount(); + } +} diff --git a/src/test/java/com/jenkins/demo/service/UserServiceTest.java b/src/test/java/com/jenkins/demo/service/UserServiceTest.java new file mode 100644 index 0000000..86f1d98 --- /dev/null +++ b/src/test/java/com/jenkins/demo/service/UserServiceTest.java @@ -0,0 +1,148 @@ +package com.jenkins.demo.service; + +import com.jenkins.demo.model.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * User Service Unit Tests + */ +class UserServiceTest { + + private UserService userService; + + @BeforeEach + void setUp() { + userService = new UserService(); + } + + @Test + @DisplayName("应该能够获取所有用户") + void shouldGetAllUsers() { + List users = userService.getAllUsers(); + assertNotNull(users); + assertEquals(3, users.size()); // 初始化数据包含3个用户 + } + + @Test + @DisplayName("应该能够根据ID获取用户") + void shouldGetUserById() { + Optional user = userService.getUserById(1L); + assertTrue(user.isPresent()); + assertEquals("admin", user.get().getUsername()); + } + + @Test + @DisplayName("获取不存在的用户应该返回空") + void shouldReturnEmptyForNonExistentUser() { + Optional user = userService.getUserById(999L); + assertFalse(user.isPresent()); + } + + @Test + @DisplayName("应该能够根据用户名查找用户") + void shouldFindUserByUsername() { + Optional user = userService.getUserByUsername("admin"); + assertTrue(user.isPresent()); + assertEquals("admin", user.get().getUsername()); + } + + @Test + @DisplayName("应该能够创建新用户") + void shouldCreateNewUser() { + User newUser = new User(null, "testuser", "test@example.com", "测试用户", null); + User createdUser = userService.createUser(newUser); + + assertNotNull(createdUser.getId()); + assertEquals("testuser", createdUser.getUsername()); + assertEquals("test@example.com", createdUser.getEmail()); + assertEquals("测试用户", createdUser.getName()); + assertEquals("ACTIVE", createdUser.getStatus()); + } + + @Test + @DisplayName("创建重复用户名应该抛出异常") + void shouldThrowExceptionForDuplicateUsername() { + User duplicateUser = new User(null, "admin", "admin2@example.com", "管理员2", null); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> userService.createUser(duplicateUser) + ); + + assertTrue(exception.getMessage().contains("用户名已存在")); + } + + @Test + @DisplayName("应该能够更新用户信息") + void shouldUpdateUser() { + User updateData = new User(null, "admin_updated", "admin_new@example.com", "更新的管理员", null); + Optional updatedUser = userService.updateUser(1L, updateData); + + assertTrue(updatedUser.isPresent()); + assertEquals("admin_updated", updatedUser.get().getUsername()); + assertEquals("admin_new@example.com", updatedUser.get().getEmail()); + assertEquals("更新的管理员", updatedUser.get().getName()); + } + + @Test + @DisplayName("更新不存在的用户应该返回空") + void shouldReturnEmptyWhenUpdatingNonExistentUser() { + User updateData = new User(null, "nonexistent", "test@example.com", "不存在的用户", null); + Optional result = userService.updateUser(999L, updateData); + + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("应该能够删除用户") + void shouldDeleteUser() { + boolean deleted = userService.deleteUser(1L); + assertTrue(deleted); + + Optional user = userService.getUserById(1L); + assertFalse(user.isPresent()); + } + + @Test + @DisplayName("删除不存在的用户应该返回false") + void shouldReturnFalseWhenDeletingNonExistentUser() { + boolean deleted = userService.deleteUser(999L); + assertFalse(deleted); + } + + @Test + @DisplayName("应该能够获取用户总数") + void shouldGetUserCount() { + long count = userService.getUserCount(); + assertEquals(3, count); + } + + @Test + @DisplayName("应该能够激活用户") + void shouldActivateUser() { + boolean activated = userService.activateUser(1L); + assertTrue(activated); + + Optional user = userService.getUserById(1L); + assertTrue(user.isPresent()); + assertEquals("ACTIVE", user.get().getStatus()); + } + + @Test + @DisplayName("应该能够停用用户") + void shouldDeactivateUser() { + boolean deactivated = userService.deactivateUser(1L); + assertTrue(deactivated); + + Optional user = userService.getUserById(1L); + assertTrue(user.isPresent()); + assertEquals("INACTIVE", user.get().getStatus()); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..e3cda0c --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,4 @@ +# Test Configuration +spring.profiles.active=test +logging.level.com.jenkins.demo=DEBUG +server.port=0