#!/usr/bin/env bash # 客服云 — 腾讯云 / 任意 Linux 一键生产部署 # 默认 HTTP_PORT=18080,避免与宿主机 Caddy(80/443) 冲突;见 deploy/caddy/Caddyfile.example # 用法: # ./deploy/scripts/deploy.sh # 构建并启动 # ./deploy/scripts/deploy.sh --seed # 启动后导入演示账号数据 # ./deploy/scripts/deploy.sh --pull-only # 仅 pull/up,不强制 rebuild(需已有镜像) # ./deploy/scripts/deploy.sh --down # 停止并移除容器(保留数据卷) # ./deploy/scripts/deploy.sh --status # 查看状态 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" COMPOSE_FILE="docker-compose.prod.yml" ENV_FILE="deploy/.env.prod" ENV_EXAMPLE="deploy/.env.prod.example" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' log() { echo -e "${GREEN}[deploy]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; } err() { echo -e "${RED}[error]${NC} $*" >&2; } need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then err "缺少命令: $1" exit 1 fi } compose() { docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@" } rand_hex() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 24 else head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 48 fi } # 检测宿主机 TCP 端口是否已被占用(非本 compose 的 web 容器) port_in_use() { local port="$1" if command -v ss >/dev/null 2>&1; then ss -tlnH "sport = :${port}" 2>/dev/null | grep -q . return $? fi if command -v lsof >/dev/null 2>&1; then lsof -iTCP:"${port}" -sTCP:LISTEN -n -P >/dev/null 2>&1 return $? fi # 无 ss/lsof 时用 bash /dev/tcp 探测(仅作弱检测) (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1 } # 端口是否由本项目的 web 容器占用(升级/重部署时允许) port_held_by_our_web() { local port="$1" docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \ | grep -E 'kefu-cloud-web' \ | grep -qE "0\\.0\\.0\\.0:${port}->|:::${port}->" } check_http_port() { local port="${HTTP_PORT:-18080}" if ! [[ "$port" =~ ^[0-9]+$ ]] || [[ "$port" -lt 1 || "$port" -gt 65535 ]]; then err "HTTP_PORT 无效: ${port}" exit 1 fi if [[ "$port" == "80" ]] || [[ "$port" == "443" ]]; then if port_in_use "$port" && ! port_held_by_our_web "$port"; then err "HTTP_PORT=${port} 已被占用(常见:宿主机 Caddy/Nginx)" err "请将 $ENV_FILE 中 HTTP_PORT 改为 18080,并由 Caddy 反代到该端口" err "参考: deploy/caddy/Caddyfile.example 与 deploy/README.md「与现有 Caddy 共存」" exit 1 fi elif port_in_use "$port" && ! port_held_by_our_web "$port"; then err "HTTP_PORT=${port} 已被其它进程占用,请改 $ENV_FILE 中的 HTTP_PORT" if command -v ss >/dev/null 2>&1; then ss -tlnp 2>/dev/null | grep -E ":${port}\\s" || true fi exit 1 fi # 80/443 被占用但用户选了 18080 时给提示 if [[ "$port" != "80" ]] && port_in_use 80; then log "检测到宿主机 :80 已被占用(多为 Caddy),将使用 HTTP_PORT=${port}" log "部署后请把 Caddy 反代到 127.0.0.1:${port}(见 deploy/caddy/Caddyfile.example)" fi } ensure_env() { if [[ ! -f "$ENV_FILE" ]]; then if [[ ! -f "$ENV_EXAMPLE" ]]; then err "找不到 $ENV_EXAMPLE" exit 1 fi log "未找到 $ENV_FILE,从示例生成…" cp "$ENV_EXAMPLE" "$ENV_FILE" local jwt dbpass minio_pass jwt="$(rand_hex)" dbpass="$(rand_hex)" minio_pass="K$(rand_hex | head -c 16)" # macOS / GNU sed 兼容 if sed --version >/dev/null 2>&1; then sed -i "s|^JWT_SECRET=.*|JWT_SECRET=${jwt}|" "$ENV_FILE" sed -i "s|^DB_PASSWORD=.*|DB_PASSWORD=${dbpass}|" "$ENV_FILE" sed -i "s|^STORAGE_SECRET_KEY=.*|STORAGE_SECRET_KEY=${minio_pass}|" "$ENV_FILE" else sed -i '' "s|^JWT_SECRET=.*|JWT_SECRET=${jwt}|" "$ENV_FILE" sed -i '' "s|^DB_PASSWORD=.*|DB_PASSWORD=${dbpass}|" "$ENV_FILE" sed -i '' "s|^STORAGE_SECRET_KEY=.*|STORAGE_SECRET_KEY=${minio_pass}|" "$ENV_FILE" fi warn "已生成随机 JWT_SECRET / DB_PASSWORD / STORAGE_SECRET_KEY" warn "请编辑 $ENV_FILE,至少设置:" warn " APP_BASE_URL=https://你的域名 # 经 Caddy 时用 https 域名,勿写 :18080" warn " STORAGE_PUBLIC_BASE_URL=https://你的域名/files" warn " HTTP_PORT=18080 # 已有 Caddy 占 80 时保持默认;独占机可改 80" echo read -r -p "设置完成后按回车继续(或 Ctrl+C 退出先改配置): " _ fi # shellcheck disable=SC1090 set -a # 去除 Windows CRLF source <(sed 's/\r$//' "$ENV_FILE" | grep -v '^\s*#' | grep -v '^\s*$' || true) set +a # 未显式设置时与 compose 默认一致 HTTP_PORT="${HTTP_PORT:-18080}" if [[ -z "${JWT_SECRET:-}" || "${JWT_SECRET}" == *"请改"* ]]; then err "请在 $ENV_FILE 中设置有效的 JWT_SECRET" exit 1 fi if [[ -z "${DB_PASSWORD:-}" || "${DB_PASSWORD}" == *"请改"* ]]; then err "请在 $ENV_FILE 中设置有效的 DB_PASSWORD" exit 1 fi if [[ -z "${STORAGE_PUBLIC_BASE_URL:-}" || "${STORAGE_PUBLIC_BASE_URL}" == *"你的"* ]]; then err "请在 $ENV_FILE 中设置 STORAGE_PUBLIC_BASE_URL(如 https://域名/files)" exit 1 fi if [[ -z "${STORAGE_ACCESS_KEY:-}" || -z "${STORAGE_SECRET_KEY:-}" ]]; then err "请设置 STORAGE_ACCESS_KEY / STORAGE_SECRET_KEY" exit 1 fi if [[ ${#STORAGE_SECRET_KEY} -lt 8 ]]; then err "STORAGE_SECRET_KEY 长度至少 8 位(MinIO 要求)" exit 1 fi check_http_port } wait_healthy() { local url="${1:-http://127.0.0.1:${HTTP_PORT:-18080}/health}" local i log "等待服务就绪: $url" for i in $(seq 1 60); do if curl -fsS "$url" >/dev/null 2>&1; then log "健康检查通过" return 0 fi sleep 2 done err "健康检查超时,请执行: docker compose -f $COMPOSE_FILE --env-file $ENV_FILE logs" return 1 } print_summary() { local port="${HTTP_PORT:-18080}" local base="${APP_BASE_URL:-http://127.0.0.1:${port}}" echo log "========== 部署完成 ==========" echo " 访问地址: ${base}" echo " 登录页: ${base}/login" echo " 健康检查: ${base}/health" echo " Widget 预览: ${base}/widget/preview" echo " 图片前缀: ${STORAGE_PUBLIC_BASE_URL}" echo " 本机探测: http://127.0.0.1:${port}/healthz (web 容器映射端口)" echo if [[ "$port" != "80" && "$port" != "443" ]]; then echo " Caddy 共存:" echo " 1) 确认本机: curl -fsS http://127.0.0.1:${port}/healthz" echo " 2) 合并 deploy/caddy/Caddyfile.example 到现有 Caddy,反代 127.0.0.1:${port}" echo " 3) APP_BASE_URL / STORAGE_PUBLIC_BASE_URL 使用 https 域名(不要写 :${port})" echo fi echo " 常用命令:" echo " 查看状态 ./deploy/scripts/deploy.sh --status" echo " 查看日志 docker compose -f $COMPOSE_FILE --env-file $ENV_FILE logs -f" echo " 导入种子 ./deploy/scripts/deploy.sh --seed-only" echo " 停止服务 ./deploy/scripts/deploy.sh --down" echo echo " 安全提示: 安全组仅放行 22/80/443(不必公网放行 ${port});改默认密码;生产用 HTTPS。" echo "==============================" } cmd_up() { local seed=0 rebuild=1 while [[ $# -gt 0 ]]; do case "$1" in --seed) seed=1 ;; --pull-only) rebuild=0 ;; *) ;; esac shift done need_cmd docker need_cmd curl if ! docker compose version >/dev/null 2>&1; then err "需要 Docker Compose V2(docker compose)" exit 1 fi ensure_env log "拉取基础镜像…" compose pull postgres minio minio-init || true if [[ "$rebuild" -eq 1 ]]; then log "构建并启动全部服务(api + web + db + minio)…" compose up -d --build --remove-orphans else log "启动服务(不强制 rebuild)…" compose up -d --remove-orphans fi wait_healthy "http://127.0.0.1:${HTTP_PORT:-18080}/health" || true wait_healthy "http://127.0.0.1:${HTTP_PORT:-18080}/healthz" || true if [[ "$seed" -eq 1 ]]; then log "导入演示种子数据…" compose --profile seed run --rm seed || warn "种子导入失败(可能已存在数据)" fi compose ps print_summary } cmd_seed_only() { need_cmd docker ensure_env log "运行 seed…" compose --profile seed run --rm seed log "种子完成。默认租户管理员 kefu_admin / kefu_admin123 (若 seed 成功)" } cmd_down() { need_cmd docker ensure_env log "停止容器(保留数据卷)…" compose down log "已停止。删除数据卷请手动: docker volume rm kefu-cloud_kefu_cloud_pgdata kefu-cloud_kefu_cloud_miniodata" } cmd_status() { need_cmd docker if [[ -f "$ENV_FILE" ]]; then compose ps echo curl -fsS "http://127.0.0.1:${HTTP_PORT:-18080}/health" && echo || warn "health 不可达" else err "缺少 $ENV_FILE" exit 1 fi } main() { local action="${1:-up}" case "$action" in up|--up|"") shift || true cmd_up "$@" ;; --seed) # 兼容:./deploy.sh --seed cmd_up --seed ;; --seed-only) cmd_seed_only ;; --down|down) cmd_down ;; --status|status) # 尝试读 HTTP_PORT if [[ -f "$ENV_FILE" ]]; then # shellcheck disable=SC1090 set -a; source <(sed 's/\r$//' "$ENV_FILE" | grep -v '^\s*#' | grep -v '^\s*$' || true); set +a fi cmd_status ;; --pull-only) cmd_up --pull-only ;; -h|--help|help) sed -n '2,12p' "$0" ;; *) err "未知参数: $action (见 --help)" exit 1 ;; esac } main "$@"