diff --git a/.deploy.env.example b/.deploy.env.example new file mode 100644 index 0000000..11b08df --- /dev/null +++ b/.deploy.env.example @@ -0,0 +1,42 @@ +# ============================================================ +# 部署配置模板(deploy.sh 使用) +# +# 使用方法(在服务器上): +# cp .deploy.env.example .deploy.env && vi .deploy.env +# +# 说明: +# - .deploy.env 不入库(.gitignore 已忽略),只写要覆盖的项 +# - 路径类默认值由 APP_NAME 派生(如 /opt/${APP_NAME}/bin),改 APP_NAME 即全部跟随 +# - 应用环境变量固定读取 ${SRC_DIR}/.env(模板见 .env.example,敏感密码放那里) +# ============================================================ + +# ---------- 应用信息 ---------- +# APP_NAME="fanqie" +# APP_DESC="番茄小说api接口" + +# ---------- 运行用户 ---------- +# 留空 = 执行 deploy.sh 的当前用户 +RUN_USER="" + +# ---------- 路径 ---------- +# 代码目录,同时也是服务的工作目录(config/config.yaml、.env、cache.db 都在这里) +SRC_DIR="/root/code/novel/fanqie-go" + +# 以下路径默认由 APP_NAME 派生,一般无需修改: +# BIN_DIR="/opt/fanqie/bin" # 默认 /opt/${APP_NAME}/bin +# SERVICE_FILE="/etc/systemd/system/fanqie.service" # 默认 /etc/systemd/system/${APP_NAME}.service +# NGINX_CONF="/etc/nginx/conf.d/fanqie.conf" # 默认 /etc/nginx/conf.d/${APP_NAME}.conf + +# ---------- 网络 ---------- +# APP_PORT="40180" +# DOMAIN="fq.20010117.xyz" + +# ---------- 证书 ---------- +# Certbot 申请证书的邮箱(默认 admin@${DOMAIN}),用于接收过期提醒 +# CERT_EMAIL="you@example.com" + +# ---------- 编译 ---------- +# BUILD_TARGET="./cmd/fanqie" + +# ---------- 其他 ---------- +# KEEP_VERSIONS=5 diff --git a/.gitignore b/.gitignore index db55d4c..4d79f69 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ go.work # Environment variables .env .env.local +.deploy.env .env.*.local # OS @@ -47,5 +48,9 @@ __pycache__ cache.db +# Build output +fanqie-* +/fanqie + # 环境变量(含真实密码,勿提交) .env diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..de4e643 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +BINARY := fanqie +VERSION ?= $(shell date +%Y%m%d-%H%M%S) +BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +BUILD_TARGET := ./cmd/fanqie + +LDFLAGS := -s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) + +.PHONY: build build-linux test clean + +build: + CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o $(BINARY)-$(VERSION) $(BUILD_TARGET) + +build-linux: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BINARY)-$(VERSION)-linux $(BUILD_TARGET) + +test: + go vet ./... && go test ./... + +clean: + rm -f $(BINARY)-* diff --git a/README.md b/README.md index 059dbfd..d60a6d5 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,36 @@ cp .env.example .env 失败响应不缓存;评论类接口为动态数据,不缓存。 +## 部署 + +### Linux 服务器(deploy.sh 一键部署) + +```bash +# 首次:编译 + 部署 + 写入 systemd/nginx 配置 +./deploy.sh v1.0.0 --init + +# 日常:拉取代码 + 编译 + 部署 + 重启 +./deploy.sh v1.0.0 + +# 版本管理 +./deploy.sh --list # 列出已部署版本 +./deploy.sh --rollback v1.0.0 # 回滚到指定版本 + +# 服务运维 +sudo systemctl status fanqie +sudo journalctl -u fanqie -f +``` + +部署结构:二进制存于 `/opt/fanqie/bin/`,`fanqie-current` 软链接指向当前版本(保留最近 5 个),systemd 守护 + Nginx 反代,部署后自动做 `/healthz` 健康检查。 + +首次 `--init` 时若服务器装有 certbot,会自动申请 HTTPS 证书并配置 80→443 跳转(邮箱默认 `admin@域名`,可用 `.deploy.env` 的 `CERT_EMAIL` 覆盖);续期由 `certbot.timer` 自动完成,续期后经 deploy-hook 重载 Nginx。 + +### 本地交叉编译 + +```bash +make build-linux VERSION=v1.0.0 # 产物 fanqie-v1.0.0-linux,scp 到服务器即可运行 +``` + ## 技术栈 - **Go 1.25** - 编程语言 diff --git a/cmd/fanqie/main.go b/cmd/fanqie/main.go index fc25243..74a4e72 100644 --- a/cmd/fanqie/main.go +++ b/cmd/fanqie/main.go @@ -16,13 +16,17 @@ import ( "time" ) +// Version / BuildTime 由编译时 -ldflags "-X main.Version=... -X main.BuildTime=..." 注入 var ( + Version = "dev" + BuildTime = "unknown" + logger = common.NewLogger("main", "INFO", "") configWatcher *config.ConfigWatcher ) func main() { - logger.Info("番茄小说API服务 - v2.0.0") + logger.Info(fmt.Sprintf("番茄小说API服务 版本=%s 编译时间=%s", Version, BuildTime)) // 加载配置文件 initConfig() diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..bca3589 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,404 @@ +#!/bin/bash +# ============================================================ +# Go 应用一键部署脚本 +# +# 用法: +# ./deploy.sh # 编译 + 部署(自动版本号) +# ./deploy.sh v1.2.0 # 编译 + 部署(指定版本) +# ./deploy.sh v1.2.0 --init # 首次初始化(写 systemd/nginx 配置) +# ./deploy.sh v1.2.0 --no-pull # 跳过 git pull +# ./deploy.sh --build-only # 只编译,不部署 +# ./deploy.sh --rollback # 交互式回滚到指定版本 +# ./deploy.sh --rollback v1.0.0 # 直接回滚到指定版本 +# ./deploy.sh --list # 列出所有已部署版本 +# ./deploy.sh --help # 显示帮助 +# ============================================================ + +set -euo pipefail + +# ==================== 加载本地配置 ==================== +# .deploy.env(不入库)覆盖下方默认值;模板见 .deploy.env.example +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "${SCRIPT_DIR}/.deploy.env" ]; then + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/.deploy.env" +fi + +# ==================== 配置区(默认值,可被 .deploy.env 覆盖) ==================== +APP_NAME="${APP_NAME:-fanqie}" # 应用名(用于服务名、目录名) +APP_DESC="${APP_DESC:-番茄小说api接口}" # 服务描述 +RUN_USER="${RUN_USER:-$(whoami)}" # 运行用户(默认当前用户) +SRC_DIR="${SRC_DIR:-/root/code/novel/fanqie-go}" # 代码目录 +BIN_DIR="${BIN_DIR:-/opt/${APP_NAME}/bin}" # 二进制存放目录 +SERVICE_FILE="${SERVICE_FILE:-/etc/systemd/system/${APP_NAME}.service}" +NGINX_CONF="${NGINX_CONF:-/etc/nginx/conf.d/${APP_NAME}.conf}" +APP_PORT="${APP_PORT:-40180}" # 程序监听端口 +DOMAIN="${DOMAIN:-fq.20010117.xyz}" # 域名 +CERT_EMAIL="${CERT_EMAIL:-admin@${DOMAIN}}" # 证书申请邮箱(过期提醒) +BUILD_TARGET="${BUILD_TARGET:-./cmd/fanqie}" # go build 的包路径 +ENV_FILE="${ENV_FILE:-${SRC_DIR}/.env}" # 应用环境变量(.env.example 为模板) +KEEP_VERSIONS="${KEEP_VERSIONS:-5}" # 保留最近几个版本 + +# ==================== 颜色与日志 ==================== +C_GREEN='\033[1;32m'; C_YELLOW='\033[1;33m'; C_RED='\033[1;31m'; C_BLUE='\033[1;34m'; C_RESET='\033[0m' +log() { echo -e "${C_GREEN}[$(date '+%H:%M:%S')]${C_RESET} $*"; } +warn() { echo -e "${C_YELLOW}[警告]${C_RESET} $*"; } +err() { echo -e "${C_RED}[错误]${C_RESET} $*" >&2; } +info() { echo -e "${C_BLUE}[信息]${C_RESET} $*"; } + +# 非 root 时自动加 sudo +SUDO="" +if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" +fi + +# ==================== 参数解析 ==================== +VERSION="" +INIT=false +NO_PULL=false +BUILD_ONLY=false +ROLLBACK=false +ROLLBACK_VERSION="" +LIST=false + +print_help() { + cat </dev/null | xargs -r basename || echo "") + info "已部署版本(${BIN_DIR}):" + echo "" + local i=1 + for f in $(ls -1t ${BIN_DIR}/${APP_NAME}-v* 2>/dev/null); do + local name + name=$(basename "$f") + if [ "$name" = "$current" ]; then + echo -e " ${C_GREEN}* ${name} (当前版本)${C_RESET}" + else + echo " ${name}" + fi + i=$((i+1)) + done + if [ $i -eq 1 ]; then + warn "没有找到任何版本" + fi +} + +# 清理旧版本 +cleanup_old_versions() { + log "清理旧版本(保留最近 ${KEEP_VERSIONS} 个)..." + local current + current=$(readlink -f "${BIN_DIR}/${APP_NAME}-current" 2>/dev/null | xargs -r basename || echo "") + local count=0 + # 按时间倒序列出,保留前 N 个,且不删当前版本 + for f in $(ls -1t ${BIN_DIR}/${APP_NAME}-v* 2>/dev/null); do + local name + name=$(basename "$f") + count=$((count+1)) + if [ $count -gt ${KEEP_VERSIONS} ] && [ "$name" != "$current" ]; then + $SUDO rm -f "$f" + log " 已删除: $name" + fi + done +} + +# 重启服务 + 验证 +restart_and_verify() { + log "重启 ${APP_NAME} 服务..." + if systemctl list-unit-files 2>/dev/null | grep -q "^${APP_NAME}.service"; then + $SUDO systemctl restart "${APP_NAME}" + $SUDO systemctl enable "${APP_NAME}" >/dev/null 2>&1 || true + else + warn "服务 ${APP_NAME}.service 不存在,请先执行 $0 --init" + return 0 + fi + + if command -v nginx >/dev/null 2>&1; then + $SUDO systemctl reload nginx 2>/dev/null || $SUDO nginx -s reload 2>/dev/null || true + fi + + sleep 2 + + # 健康自检:确认服务真正响应,而非仅进程存活 + if command -v curl >/dev/null 2>&1; then + if curl -fsS --max-time 5 "http://127.0.0.1:${APP_PORT}/healthz" >/dev/null 2>&1; then + log "健康检查通过: /healthz" + else + err "健康检查失败: http://127.0.0.1:${APP_PORT}/healthz" + warn "服务可能启动异常,如需回滚执行: $0 --rollback" + $SUDO journalctl -u "${APP_NAME}" -n 30 --no-pager || true + exit 1 + fi + fi + + echo "" + log "服务状态:" + $SUDO systemctl status "${APP_NAME}" --no-pager -l | head -n 15 || true + echo "" + log "最近日志:" + $SUDO journalctl -u "${APP_NAME}" -n 15 --no-pager || true +} + +# ==================== --list ==================== +if [ "$LIST" = true ]; then + list_versions + exit 0 +fi + +# ==================== --rollback ==================== +if [ "$ROLLBACK" = true ]; then + if [ ! -d "${BIN_DIR}" ]; then + err "二进制目录不存在: ${BIN_DIR}" + exit 1 + fi + + # 没指定版本,交互式选择 + if [ -z "$ROLLBACK_VERSION" ]; then + list_versions + echo "" + read -rp "输入要回滚到的版本号(如 ${APP_NAME}-v1.0.0,直接回车取消): " TARGET + [ -z "$TARGET" ] && { info "已取消"; exit 0; } + ROLLBACK_VERSION="$TARGET" + fi + + # 补全前缀 + if [[ "$ROLLBACK_VERSION" != ${APP_NAME}-* ]]; then + ROLLBACK_VERSION="${APP_NAME}-${ROLLBACK_VERSION}" + fi + + TARGET_PATH="${BIN_DIR}/${ROLLBACK_VERSION}" + if [ ! -f "$TARGET_PATH" ]; then + err "版本不存在: $TARGET_PATH" + exit 1 + fi + + log "回滚到: ${ROLLBACK_VERSION}" + switch_symlink "${ROLLBACK_VERSION}" + restart_and_verify + echo "" + log "✅ 已回滚到 ${ROLLBACK_VERSION}" + exit 0 +fi + +# ==================== 1. 前置检查 ==================== +log "检查环境..." +command -v go >/dev/null 2>&1 || { err "未找到 go,请先安装 Go"; exit 1; } +command -v nginx >/dev/null 2>&1 || warn "未检测到 nginx" + +cd "$SRC_DIR" || { err "代码目录不存在: $SRC_DIR"; exit 1; } + +# ==================== 2. 拉取代码 ==================== +if [ "$NO_PULL" = false ] && [ -d .git ]; then + log "拉取最新代码..." + git pull --ff-only || warn "git pull 失败,继续使用当前代码" +else + log "跳过 git pull" +fi + +# ==================== 3. 编译 ==================== +log "编译 ${APP_NAME} (版本: ${VERSION})..." +CGO_ENABLED=0 go build \ + -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \ + -o "${APP_NAME}-${VERSION}" \ + "${BUILD_TARGET}" + +[ -f "${APP_NAME}-${VERSION}" ] || { err "编译产物不存在"; exit 1; } +log "编译完成: ${APP_NAME}-${VERSION}" + +# ==================== --build-only ==================== +if [ "$BUILD_ONLY" = true ]; then + echo "" + log "✅ 仅编译模式,产物:$(pwd)/${APP_NAME}-${VERSION}" + echo " 如需部署,请执行: $0 ${VERSION} --no-pull" + exit 0 +fi + +# ==================== 4. 部署二进制 ==================== +log "部署到 ${BIN_DIR}..." +$SUDO mkdir -p "${BIN_DIR}" +$SUDO mv "${APP_NAME}-${VERSION}" "${BIN_DIR}/" +$SUDO chmod +x "${BIN_DIR}/${APP_NAME}-${VERSION}" + +switch_symlink "${APP_NAME}-${VERSION}" +log "软链接已切换到: ${APP_NAME}-${VERSION}" + +cleanup_old_versions + +# ==================== 5. 首次初始化 ==================== +if [ "$INIT" = true ]; then + log "初始化 systemd 服务: ${SERVICE_FILE}" + $SUDO tee "${SERVICE_FILE}" >/dev/null </dev/null </dev/null <<'HOOK' +#!/bin/bash +if /usr/sbin/nginx -t 2>/dev/null; then + systemctl reload nginx +fi +HOOK + $SUDO chmod +x "${HOOK_FILE}" + fi + + # ---- 首次初始化自动申请 HTTPS 证书(一次性;续期由 certbot.timer 自动处理)---- + if [ ! -d "/etc/letsencrypt/live/${DOMAIN}" ]; then + if command -v certbot >/dev/null 2>&1; then + log "未检测到证书,尝试自动申请: ${DOMAIN}" + $SUDO certbot --nginx -d "${DOMAIN}" --non-interactive --agree-tos -m "${CERT_EMAIL}" --redirect \ + || warn "证书申请失败,请检查域名解析与 80 端口;可稍后手动执行 certbot --nginx -d ${DOMAIN}" + else + warn "未安装 certbot,跳过证书申请。可手动执行: dnf install certbot python3-certbot-nginx" + fi + else + log "证书已存在,跳过申请" + fi + + if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then + log "检测到 SELinux,放行 Nginx 网络连接..." + $SUDO setsebool -P httpd_can_network_connect 1 || warn "setsebool 失败" + fi + + if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then + log "放行防火墙 80/443..." + $SUDO firewall-cmd --permanent --add-service=http >/dev/null + $SUDO firewall-cmd --permanent --add-service=https >/dev/null + $SUDO firewall-cmd --reload >/dev/null + fi + + log "重载 systemd..." + $SUDO systemctl daemon-reload +fi + +# ==================== 6. 校验 Nginx ==================== +if [ -f "${NGINX_CONF}" ]; then + log "校验 Nginx 配置..." + $SUDO nginx -t || { err "Nginx 配置有误"; exit 1; } +fi + +# ==================== 7. 重启服务 + 验证 ==================== +restart_and_verify + +echo "" +log "✅ 部署完成!" +echo " 版本: ${VERSION}" +echo " 二进制: ${BIN_DIR}/${APP_NAME}-current" +echo " 端口: ${APP_PORT}" +echo " 域名: http://${DOMAIN}" +echo "" +echo "常用命令:" +echo " 查看状态: sudo systemctl status ${APP_NAME}" +echo " 查看日志: sudo journalctl -u ${APP_NAME} -f" +echo " 重启服务: sudo systemctl restart ${APP_NAME}" +echo " 列出版本: $0 --list" +echo " 回滚版本: $0 --rollback"