# SmartClean 本地模拟自动化打包部署方案

## 一、目标

在本机用 Docker 模拟真实服务器环境，实现 **一键构建 → 镜像打包 → 部署 → 健康检查 → 回滚 → 飞书通知** 全流程自动化，为后续迁移到远程服务器 / CI/CD 打基础。

## 二、整体架构

```
开发者本机
│
├── 源码 (smartclean/)
│
├── deploy.sh 一键触发
│   ├── 1. Maven/npm 构建产物
│   ├── 2. Docker 镜像打包（带版本 tag）
│   ├── 3. Docker Compose 部署（替换容器）
│   ├── 4. 健康检查（轮询接口）
│   ├── 5. 失败自动回滚（切回上一版本镜像）
│   └── 6. 飞书 Webhook 通知结果
│
└── Docker 容器组（模拟生产服务器）
    ┌──────────────────────────────────────────────┐
    │  smartclean-front   (Nginx,       端口 80)    │
    │  smartclean-web     (Tomcat 8.5,  端口 18095) │
    │  smartclean-task    (Spring Boot, 端口 18097) │
    │  smartclean-mysql   (MySQL 5.7,   端口 3307)  │
    │  smartclean-redis   (Redis 6,     端口 6380)  │
    └──────────────────────────────────────────────┘
```

> 端口刻意与本机开发环境错开，两套环境完全独立、互不干扰。

## 三、新增文件结构

```
smartclean/
└── deploy/
    ├── docker-compose.yml            # 服务编排定义
    ├── .env                          # 环境变量（密码等敏感信息，不入 git）
    │
    ├── Dockerfile.web                # Web 服务镜像（Tomcat + ROOT.war）
    ├── Dockerfile.task               # Task 服务镜像（JRE + JAR）
    ├── Dockerfile.front              # 前端镜像（Nginx + 静态文件）
    │
    ├── conf/
    │   ├── nginx.conf                # Nginx 反代配置（前端 → 后端）
    │   ├── application-docker.yml    # Web 服务 Docker 专用 Spring 配置
    │   └── application-task-docker.yml  # Task 服务 Docker 专用 Spring 配置
    │
    ├── init-sql/
    │   └── init.sh                   # 首次启动时自动建库导数据
    │
    ├── deploy.sh                     # 一键部署脚本（主入口）
    └── rollback.sh                   # 一键回滚脚本
```

## 四、各组件详细设计

### 4.1 Docker Compose 编排

```yaml
# deploy/docker-compose.yml
version: '3.8'

services:
  # ==================== 基础设施 ====================
  mysql:
    image: mysql:5.7
    container_name: smartclean-mysql
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
      - ../sql:/docker-entrypoint-initdb.d    # 首次启动自动导入 SQL
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      retries: 5
    networks:
      - smartclean

  redis:
    image: redis:6-alpine
    container_name: smartclean-redis
    ports:
      - "6380:6379"
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      retries: 3
    networks:
      - smartclean

  # ==================== 应用服务 ====================
  web:
    image: smartclean-web:${VERSION:-latest}
    container_name: smartclean-web
    build:
      context: ..
      dockerfile: deploy/Dockerfile.web
    ports:
      - "18095:8095"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: docker
    volumes:
      - ./conf/application-docker.yml:/app/config/application-docker.yml
      - web_logs:/app/logs
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8095/dropDown/districtTree"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 60s
    restart: on-failure:3
    networks:
      - smartclean

  task:
    image: smartclean-task:${VERSION:-latest}
    container_name: smartclean-task
    build:
      context: ..
      dockerfile: deploy/Dockerfile.task
    ports:
      - "18097:8097"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      SPRING_PROFILES_ACTIVE: docker
    volumes:
      - ./conf/application-task-docker.yml:/app/config/application-task-docker.yml
      - task_logs:/app/logs
    restart: on-failure:3
    networks:
      - smartclean

  frontend:
    image: smartclean-front:${VERSION:-latest}
    container_name: smartclean-front
    build:
      context: ..
      dockerfile: deploy/Dockerfile.front
    ports:
      - "80:80"
    depends_on:
      - web
    volumes:
      - ./conf/nginx.conf:/etc/nginx/conf.d/default.conf
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost/"]
      interval: 10s
      retries: 3
    restart: on-failure:3
    networks:
      - smartclean

volumes:
  mysql_data:
  redis_data:
  web_logs:
  task_logs:

networks:
  smartclean:
    driver: bridge
```

### 4.2 Dockerfile

#### Web 服务（Tomcat + WAR）

```dockerfile
# deploy/Dockerfile.web
FROM maven:3.8-openjdk-8 AS builder
WORKDIR /src
COPY backend/xiaoqu-intellectual-public/ xiaoqu-intellectual-public/
COPY backend/xiaoqu-intellectual-web/ xiaoqu-intellectual-web/
RUN cd xiaoqu-intellectual-public && mvn clean install -q -DskipTests \
 && cd ../xiaoqu-intellectual-web && mvn clean package -q -DskipTests

FROM tomcat:8.5-jdk8-temurin
RUN rm -rf /usr/local/tomcat/webapps/*
COPY --from=builder /src/xiaoqu-intellectual-web/target/ROOT.war /usr/local/tomcat/webapps/
# 注入外部配置目录
ENV SPRING_CONFIG_ADDITIONAL_LOCATION=/app/config/
RUN mkdir -p /app/config /app/logs
EXPOSE 8095
```

#### Task 服务（Spring Boot JAR）

```dockerfile
# deploy/Dockerfile.task
FROM maven:3.8-openjdk-8 AS builder
WORKDIR /src
COPY backend/xiaoqu-intellectual-public/ xiaoqu-intellectual-public/
COPY backend/xiaoqu-intellectual-task/ xiaoqu-intellectual-task/
RUN cd xiaoqu-intellectual-public && mvn clean install -q -DskipTests \
 && cd ../xiaoqu-intellectual-task && mvn clean package -q -DskipTests

FROM openjdk:8-jre-slim
WORKDIR /app
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/xiaoqu-intellectual-task/target/*.jar app.jar
RUN mkdir -p /app/config /app/logs
EXPOSE 8097
ENTRYPOINT ["java", "-jar", "app.jar", \
  "--spring.config.additional-location=/app/config/"]
```

#### 前端（多阶段构建 → Nginx）

```dockerfile
# deploy/Dockerfile.front
FROM node:16-alpine AS builder
WORKDIR /app
COPY frontend/witcleansystem/package*.json ./
RUN npm ci --registry=https://registry.npmmirror.com
COPY frontend/witcleansystem/ ./
RUN npm run build

FROM nginx:1.24-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
```

### 4.3 Nginx 反代配置

```nginx
# deploy/conf/nginx.conf
server {
    listen 80;
    server_name localhost;

    # 前端静态文件
    location / {
        root   /usr/share/nginx/html;
        index  index.html;
        try_files $uri $uri/ /index.html;   # SPA history fallback
    }

    # API 反代到后端 Web 服务
    location /api/ {
        proxy_pass http://web:8095/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        root /usr/share/nginx/html;
        expires 7d;
        add_header Cache-Control "public, immutable";
    }
}
```

### 4.4 Docker 专用 Spring 配置

```yaml
# deploy/conf/application-docker.yml
# 覆盖 application-test.yml 中的远程地址，指向 Docker 容器
server:
  port: 8095

redis:
  host: redis          # Docker 容器名
  port: 6379
  password: ${REDIS_PASSWORD:kaixinjiuhao}

spring:
  datasource:
    db1:
      url: jdbc:mysql://mysql:3306/xiaoqu_comples_d?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true&allowPublicKeyRetrieval=true
      username: root
      password: ${DB_PASSWORD:kaixinjiuhao}
    db2:
      url: jdbc:mysql://mysql:3306/xiaoqu_intellectual_d?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true&allowPublicKeyRetrieval=true
      username: root
      password: ${DB_PASSWORD:kaixinjiuhao}

xxl:
  job:
    admin:
      addresses: http://xxl-job-admin:8080/xxl-job-admin   # 可选，本地模拟可留空
```

### 4.5 环境变量文件

```bash
# deploy/.env（不入 git）
DB_PASSWORD=kaixinjiuhao
REDIS_PASSWORD=kaixinjiuhao
VERSION=latest
FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/5703e8cc-6998-46a6-af9d-8c5102cc8c1e
```

## 五、一键部署脚本

```bash
# deploy/deploy.sh
#!/bin/bash
#
# SmartClean 一键部署脚本（本地 Docker 模拟）
#
# 用法:
#   ./deploy.sh              # 部署所有服务
#   ./deploy.sh web          # 仅重建部署 Web 服务
#   ./deploy.sh task         # 仅重建部署 Task 服务
#   ./deploy.sh front        # 仅重建部署前端
#   ./deploy.sh rollback     # 回滚到上一版本

set -e

DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(dirname "$DEPLOY_DIR")"
source "$DEPLOY_DIR/.env"

# ===== 版本号：日期-git短hash =====
GIT_HASH=$(cd "$ROOT_DIR" && git rev-parse --short HEAD)
VERSION="v$(date +%Y%m%d)-${GIT_HASH}"
BACKUP_FILE="$DEPLOY_DIR/.last-version"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

log_info()  { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }

# ===== 飞书通知 =====
notify_feishu() {
    local title="$1" content="$2" color="$3"
    curl -s -X POST "$FEISHU_WEBHOOK" \
      -H "Content-Type: application/json" \
      -d "{
        \"msg_type\": \"interactive\",
        \"card\": {
          \"header\": {\"title\":{\"tag\":\"plain_text\",\"content\":\"$title\"},\"template\":\"$color\"},
          \"elements\": [{\"tag\":\"markdown\",\"content\":\"$content\"}]
        }
      }" > /dev/null 2>&1
}

# ===== 备份当前版本号 =====
backup_version() {
    if [ -f "$DEPLOY_DIR/.current-version" ]; then
        cp "$DEPLOY_DIR/.current-version" "$BACKUP_FILE"
        log_info "已备份当前版本: $(cat "$BACKUP_FILE")"
    fi
}

# ===== 构建镜像 =====
build_images() {
    local target="${1:-all}"
    local start_time=$(date +%s)

    cd "$DEPLOY_DIR"

    case "$target" in
        web)
            log_info "构建 Web 镜像 ($VERSION)..."
            docker compose build web
            ;;
        task)
            log_info "构建 Task 镜像 ($VERSION)..."
            docker compose build task
            ;;
        front)
            log_info "构建前端镜像 ($VERSION)..."
            docker compose build frontend
            ;;
        all)
            log_info "构建全部镜像 ($VERSION)..."
            docker compose build web task frontend
            ;;
    esac

    local elapsed=$(( $(date +%s) - start_time ))
    log_info "镜像构建完成 (${elapsed}s)"
}

# ===== 部署服务 =====
deploy_services() {
    local target="${1:-all}"

    cd "$DEPLOY_DIR"
    export VERSION

    # 确保基础设施先启动
    log_info "确保 MySQL + Redis 运行中..."
    docker compose up -d mysql redis
    log_info "等待数据库就绪..."
    docker compose exec mysql mysqladmin ping -h localhost --wait=30 --silent 2>/dev/null

    case "$target" in
        web)   docker compose up -d --no-deps web ;;
        task)  docker compose up -d --no-deps task ;;
        front) docker compose up -d --no-deps frontend ;;
        all)   docker compose up -d web task frontend ;;
    esac

    log_info "容器已启动，开始健康检查..."
}

# ===== 健康检查 =====
healthcheck() {
    local target="${1:-all}"
    local max_retries=20
    local interval=5

    # 检查 Web 服务
    if [ "$target" = "all" ] || [ "$target" = "web" ]; then
        log_info "检查 Web 服务..."
        for i in $(seq 1 $max_retries); do
            if curl -sf http://localhost:18095/dropDown/districtTree > /dev/null 2>&1; then
                log_info "✅ Web 服务健康 (第${i}次检查)"
                break
            fi
            if [ $i -eq $max_retries ]; then
                log_error "❌ Web 服务健康检查失败"
                return 1
            fi
            sleep $interval
        done
    fi

    # 检查前端
    if [ "$target" = "all" ] || [ "$target" = "front" ]; then
        log_info "检查前端服务..."
        for i in $(seq 1 $max_retries); do
            if curl -sf http://localhost:80/ > /dev/null 2>&1; then
                log_info "✅ 前端服务健康 (第${i}次检查)"
                break
            fi
            if [ $i -eq $max_retries ]; then
                log_error "❌ 前端服务健康检查失败"
                return 1
            fi
            sleep $interval
        done
    fi

    return 0
}

# ===== 回滚 =====
rollback() {
    if [ ! -f "$BACKUP_FILE" ]; then
        log_error "没有可回滚的版本"
        exit 1
    fi

    local old_version=$(cat "$BACKUP_FILE")
    log_warn "回滚到版本: $old_version"

    export VERSION="$old_version"
    cd "$DEPLOY_DIR"
    docker compose up -d web task frontend

    echo "$old_version" > "$DEPLOY_DIR/.current-version"
    log_info "回滚完成"

    notify_feishu "⚠️ SmartClean 已回滚" \
      "**回滚版本:** $old_version\\n**触发原因:** 健康检查失败" \
      "yellow"
}

# ===== 主流程 =====
TARGET="${1:-all}"
DEPLOY_START=$(date +%s)
BRANCH=$(cd "$ROOT_DIR" && git rev-parse --abbrev-ref HEAD)
COMMIT=$(cd "$ROOT_DIR" && git log -1 --format='%h %s')

if [ "$TARGET" = "rollback" ]; then
    rollback
    exit 0
fi

log_info "=============================="
log_info " SmartClean 自动化部署"
log_info " 版本: $VERSION"
log_info " 分支: $BRANCH"
log_info " 目标: $TARGET"
log_info "=============================="

# 1. 备份当前版本
backup_version

# 2. 构建镜像
build_images "$TARGET"

# 3. 部署
deploy_services "$TARGET"

# 4. 健康检查
if healthcheck "$TARGET"; then
    # 记录当前版本
    echo "$VERSION" > "$DEPLOY_DIR/.current-version"

    ELAPSED=$(( $(date +%s) - DEPLOY_START ))
    log_info "=============================="
    log_info " ✅ 部署成功！耗时 ${ELAPSED}s"
    log_info " 前端: http://localhost"
    log_info " Web:  http://localhost:18095"
    log_info " Task: http://localhost:18097"
    log_info "=============================="

    notify_feishu "✅ SmartClean 部署成功" \
      "**版本:** $VERSION\\n**分支:** $BRANCH\\n**提交:** $COMMIT\\n**目标:** $TARGET\\n**耗时:** ${ELAPSED}s" \
      "green"
else
    log_error "健康检查失败，自动回滚..."
    rollback

    ELAPSED=$(( $(date +%s) - DEPLOY_START ))
    notify_feishu "❌ SmartClean 部署失败（已回滚）" \
      "**版本:** $VERSION\\n**分支:** $BRANCH\\n**提交:** $COMMIT\\n**耗时:** ${ELAPSED}s\\n**状态:** 已自动回滚" \
      "red"

    exit 1
fi
```

## 六、回滚脚本

```bash
# deploy/rollback.sh
#!/bin/bash
# 快捷回滚入口
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$DEPLOY_DIR/deploy.sh" rollback
```

## 七、使用方式

### 首次启动

```bash
cd deploy/

# 1. 创建 .env 文件（设置密码等）
cp .env.example .env

# 2. 一键构建 + 部署全部服务
./deploy.sh
```

### 日常使用

```bash
# 修改了后端代码，只重建部署 Web 服务
./deploy.sh web

# 修改了前端代码，只重建部署前端
./deploy.sh front

# 全量部署
./deploy.sh

# 出问题了，一键回滚
./deploy.sh rollback
# 或
./rollback.sh
```

### 查看日志

```bash
# 查看所有服务日志
docker compose -f deploy/docker-compose.yml logs -f

# 只看 Web 服务
docker compose -f deploy/docker-compose.yml logs -f web

# 查看容器状态
docker compose -f deploy/docker-compose.yml ps
```

### 停止 / 清理

```bash
# 停止所有容器（数据保留）
docker compose -f deploy/docker-compose.yml down

# 停止并删除数据卷（慎用，会丢数据库数据）
docker compose -f deploy/docker-compose.yml down -v
```

## 八、端口映射总览

| 服务 | 容器内端口 | 宿主机端口 | 用途 |
|------|-----------|-----------|------|
| 前端 (Nginx) | 80 | **80** | 浏览器访问入口 |
| Web 后端 | 8095 | **18095** | API 接口（也可通过 Nginx `/api` 访问） |
| Task 后端 | 8097 | **18097** | 定时任务服务 |
| MySQL | 3306 | **3307** | 本机连接调试用 |
| Redis | 6379 | **6380** | 本机连接调试用 |

> 与本机开发环境 (8079/8095/3306/6379) 完全隔离。

## 九、与本机开发环境的对比

```
┌─────────────────────────────┐     ┌─────────────────────────────┐
│     本机开发环境（已有）       │     │    Docker 模拟部署（新增）     │
│                             │     │                             │
│  npm run dev → :8079        │     │  Nginx → :80                │
│  mvn spring-boot:run → :8095│     │  Tomcat 容器 → :18095       │
│  本机 MySQL → :3306          │     │  MySQL 容器 → :3307          │
│  本机 Redis → :6379          │     │  Redis 容器 → :6380          │
│                             │     │                             │
│  用途: 开发调试、热重载       │     │  用途: 模拟生产部署流程       │
└─────────────────────────────┘     └─────────────────────────────┘
```

## 十、后续演进路径

本方案在本机验证通过后，可以低成本迁移到真实环境：

| 阶段 | 动作 | 改动量 |
|------|------|--------|
| **当前** | 本机 Docker 模拟 | 本方案 |
| **阶段 2** | 远程服务器部署 | deploy.sh 加 SSH 远程执行，或把镜像 push 到私有 Registry |
| **阶段 3** | Gitea Actions CI/CD | 把 deploy.sh 的逻辑搬到 `.gitea/workflows/deploy.yml`，push 自动触发 |
| **阶段 4** | 多环境 | docker-compose.prod.yml 覆盖生产配置，同一套镜像部署到不同环境 |

每个阶段都是增量改动，核心的 Dockerfile 和 docker-compose.yml 不需要重写。
