#!/usr/bin/env bash ############################################################ # zsAPI 一键部署脚本 # 适用:Ubuntu 20.04 / 22.04 / 24.04、Debian 11 / 12 # x86_64 与 arm64 都可以 # # 用法(交互式,推荐): # curl -fsSL https://deploy.0lt.top/zsapi.sh -o zsapi.sh # bash zsapi.sh # # 静默安装(全部可跳过询问): # DOMAIN=api.example.com SITE_NAME="我的API" ADMIN_PASS=xxx bash zsapi.sh --yes # # 可选环境变量:DOMAIN / SITE_NAME / ADMIN_USER / ADMIN_EMAIL / ADMIN_PASS / # PKG_URL / LE_EMAIL / FORCE=1 ############################################################ set -euo pipefail PKG_URL="${PKG_URL:-https://deploy.0lt.top/zsapi-deploy.tar.gz}" SERVER_PORT="${SERVER_PORT:-8026}" BE_DIR="${BE_DIR:-/opt/zsapi-go}" FE_DIR="${FE_DIR:-/opt/zsapi-vue}" DATA_DIR="${DATA_DIR:-/opt/zsapi/data}" CONF_DIR="${CONF_DIR:-/etc/zsapi}" LOG_FILE="${LOG_FILE:-/var/log/zsapi-go.log}" PKG_DIR="${PKG_DIR:-/opt/zsapi-deploy}" SVC_NAME="${SVC_NAME:-zsapi-go}" NGINX_CONF="${NGINX_CONF:-zsapi.conf}" UPLOAD_DIR="${UPLOAD_DIR:-/opt/zsapi/uploads}" LISTEN_PORT="${LISTEN_PORT:-80}" # 网站对外端口(本机多实例体验时改它) c_red=$'\033[31m'; c_grn=$'\033[32m'; c_yel=$'\033[33m'; c_cya=$'\033[36m'; c_bold=$'\033[1m'; c_off=$'\033[0m' info(){ printf '%s[信息]%s %s\n' "$c_cya" "$c_off" "$*"; } ok(){ printf '%s[完成]%s %s\n' "$c_grn" "$c_off" "$*"; } warn(){ printf '%s[注意]%s %s\n' "$c_yel" "$c_off" "$*"; } die(){ printf '%s[失败]%s %s\n' "$c_red" "$c_off" "$*" >&2; printf '%s 安装日志:%s(把它发出来即可定位问题)%s\n' "$c_yel" "${INSTALL_LOG:--}" "$c_off" >&2; exit 1; } step(){ printf '\n%s==> %s%s\n' "$c_bold" "$*" "$c_off"; } # 全程留一份日志在本地,出错时把日志发出来就能定位 INSTALL_LOG="${INSTALL_LOG:-/var/log/zsapi-install.log}" if [ "$INSTALL_LOG" != "-" ] && [ -w /var/log ]; then exec > >(tee -a "$INSTALL_LOG") 2>&1 fi ASSUME_YES=0 for a in "$@"; do case "$a" in -y|--yes) ASSUME_YES=1 ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; *) warn "忽略未知参数:$a" ;; esac done # ---------- 参数 ---------- DOMAIN="${DOMAIN:-}" SITE_NAME="${SITE_NAME:-}" ADMIN_USER="${ADMIN_USER:-admin}" ADMIN_EMAIL="${ADMIN_EMAIL:-}" ADMIN_PASS="${ADMIN_PASS:-}" LE_EMAIL="${LE_EMAIL:-}" FORCE="${FORCE:-0}" ask(){ local var="$1" prompt="$2" def="${3:-}" silent="${4:-0}" cur ans cur="${!var}" if [ -n "$cur" ]; then return 0; fi if [ "$ASSUME_YES" = 1 ] || [ ! -t 0 ]; then printf -v "$var" '%s' "$def"; return 0 fi if [ "$silent" = 1 ]; then read -rsp "$prompt" ans /dev/null ok "系统依赖就绪($(nginx -v 2>&1 | sed 's#nginx version: ##'),php $(php -r 'echo PHP_VERSION;'))" # ---------- 2. Go / Node ---------- step "2/8 准备编译环境(Go + Node.js)" # 常见的手工安装路径先放进 PATH,避免误判「没装」而重复下载 export PATH="/usr/local/go/bin:/usr/local/bin:$PATH" for d in /usr/local/node-*/bin /usr/local/lib/nodejs/*/bin; do [ -d "$d" ] && PATH="$d:$PATH" done export PATH need_go=1 if command -v go >/dev/null 2>&1; then gv=$(go version | awk '{print $3}') case "$gv" in go1.2[5-9]*|go1.[3-9][0-9]*|go[2-9].*) need_go=0; info "已有 $gv" ;; *) warn "已装 $gv 版本偏低(需要 ≥ 1.25),将安装新版" ;; esac fi if [ "$need_go" = 1 ]; then GOVER=$(curl -fsSL --max-time 15 'https://go.dev/VERSION?m=text' 2>/dev/null | head -n1 || true) case "$GOVER" in go1.*) : ;; *) GOVER=go1.25.0 ;; esac case "$(uname -m)" in x86_64) GARCH=amd64 ;; aarch64|arm64) GARCH=arm64 ;; *) die "不支持的 CPU 架构:$(uname -m)" ;; esac info "下载 ${GOVER}(约 70MB)..." curl -fL --retry 3 --max-time 600 -o /tmp/go.tgz "https://go.dev/dl/${GOVER}.linux-${GARCH}.tar.gz" rm -rf /usr/local/go tar -C /usr/local -xzf /tmp/go.tgz rm -f /tmp/go.tgz fi NODE_BIN="" if command -v node >/dev/null 2>&1; then nmaj=$(node -v | sed 's/^v//;s/\..*//') if [ "$nmaj" -ge 18 ] 2>/dev/null; then NODE_BIN=node; info "已有 node $(node -v)"; fi fi if [ -z "$NODE_BIN" ]; then NVER=v22.11.0 case "$(uname -m)" in x86_64) NARCH=x64 ;; aarch64|arm64) NARCH=arm64 ;; *) die "不支持的 CPU 架构:$(uname -m)" ;; esac info "下载 Node ${NVER}(约 30MB)..." curl -fL --retry 3 --max-time 600 -o /tmp/node.tar.xz "https://nodejs.org/dist/${NVER}/node-${NVER}-linux-${NARCH}.tar.xz" mkdir -p /usr/local/lib/nodejs tar -xJf /tmp/node.tar.xz -C /usr/local/lib/nodejs rm -f /tmp/node.tar.xz NODE_ROOT="/usr/local/lib/nodejs/node-${NVER}-linux-${NARCH}" ln -sf "$NODE_ROOT/bin/node" /usr/local/bin/node ln -sf "$NODE_ROOT/bin/npm" /usr/local/bin/npm ln -sf "$NODE_ROOT/bin/npx" /usr/local/bin/npx fi export PATH="/usr/local/go/bin:/usr/local/bin:$PATH" ok "Go $(go version | awk '{print $3}'),Node $(node -v)" # ---------- 3. 取源码 ---------- step "3/8 下载 zsAPI 源码包" SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [ -f "$SELF_DIR/zsapi-deploy.tar.gz" ] && tar -tzf "$SELF_DIR/zsapi-deploy.tar.gz" 2>/dev/null | grep -q '/src/backend/'; then PKG="$SELF_DIR/zsapi-deploy.tar.gz" info "使用脚本同目录的 zsapi-deploy.tar.gz" else info "从 $PKG_URL 下载 ..." curl -fL --retry 3 --max-time 300 -o /tmp/zsapi-deploy.tar.gz "$PKG_URL" PKG=/tmp/zsapi-deploy.tar.gz fi rm -rf "$PKG_DIR" /tmp/zsapi-pkg-extract mkdir -p /tmp/zsapi-pkg-extract "$(dirname "$PKG_DIR")" tar -xzf "$PKG" -C /tmp/zsapi-pkg-extract BESRC=$(find /tmp/zsapi-pkg-extract -maxdepth 3 -type d -path '*/src/backend' | head -n1) [ -n "$BESRC" ] || die "源码包结构不对(包里找不到 src/backend)" PKG_SRC="${BESRC%/src/backend}" mkdir -p "$PKG_DIR" cp -a "$PKG_SRC/." "$PKG_DIR/" rm -rf /tmp/zsapi-pkg-extract [ -d "$PKG_DIR/src/backend" ] || die "源码解压失败:$PKG_DIR/src/backend 不存在" ok "源码已解压到 $PKG_DIR" rm -rf "$BE_DIR" "$FE_DIR" mkdir -p "$BE_DIR" "$FE_DIR" "$DATA_DIR" "$UPLOAD_DIR/avatars" "$CONF_DIR" cp -a "$PKG_DIR/src/backend/." "$BE_DIR/" cp -a "$PKG_DIR/src/frontend/." "$FE_DIR/" # ---------- 4. 配置文件 ---------- step "4/8 写配置文件 /etc/zsapi/app.php" sed -e "s#^\( *'public_base_url' *=> *\)''#\1'${PUBLIC_BASE}'#" \ -e "s#^\( *'app_secret' *=> *\)''#\1'$(rand32)'#" \ -e "s#^\( *'callback_secret' *=> *\)''#\1'$(rand32)'#" \ "$PKG_DIR/app.php.template" > "$CONF_DIR/app.php" chown root:www-data "$CONF_DIR/app.php" chmod 640 "$CONF_DIR/app.php" ok "已生成(里面所有支付/邮件项都是空的,填了才生效)" # ---------- 5. 建库 ---------- step "5/8 初始化数据库" SITE_NAME="$SITE_NAME" python3 - "$DATA_DIR/zsapi.sqlite" "$PKG_DIR/schema.sql" <<'PY' import os, sqlite3, sys, time db, schema = sys.argv[1], sys.argv[2] con = sqlite3.connect(db) con.executescript(open(schema, encoding='utf-8').read()) now = int(time.time()) con.execute("CREATE TABLE IF NOT EXISTS system_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)") con.execute("INSERT OR IGNORE INTO system_settings(key,value,updated_at) VALUES('site_title',?,?)", (os.environ.get('SITE_NAME') or 'API 平台', now)) con.execute("INSERT OR IGNORE INTO system_settings(key,value,updated_at) VALUES('registration_enabled','1',?)", (now,)) con.execute("INSERT OR IGNORE INTO groups(id,name,multiplier,created_at,hidden,parent_id) VALUES(1,'默认分组',1.0,?,0,0)", (now,)) con.commit(); con.close() print(' 表数量:', len([r for r in sqlite3.connect(db).execute("select name from sqlite_master where type='table'")])) PY chown -R www-data:www-data "$DATA_DIR" "$UPLOAD_DIR" chmod 750 "$DATA_DIR" ok "数据库:$DATA_DIR/zsapi.sqlite" # ---------- 6. 编译后端 ---------- step "6/8 编译后端(第一次要下载依赖,几分钟)" export HOME=/root export GOPATH=/root/go export GOMODCACHE=/root/go/pkg/mod export GOPROXY="${GOPROXY:-https://goproxy.cn,https://proxy.golang.org,direct}" export GOFLAGS=-mod=mod mkdir -p /root/go/pkg/mod cd "$BE_DIR" # 对齐源码里的固定路径与端口(用默认目录时这几条等于没改) sed -i "s#^const DBPath = .*#const DBPath = \"$DATA_DIR/zsapi.sqlite\"#" db.go sed -i "s#/etc/zsapi/app.php#$CONF_DIR/app.php#g" *.go sed -i "s#127.0.0.1:8026#127.0.0.1:$SERVER_PORT#g" main.go go build -o zsapi-go-server . || die "后端编译失败,看上面报错" ok "后端二进制:$BE_DIR/zsapi-go-server" # 管理员账号(用 Go 自己的 bcrypt,保证和登录逻辑一致) mkdir -p "$BE_DIR/tools/adminseed" cat > "$BE_DIR/tools/adminseed/main.go" <<'GOEOF' package main import ( "database/sql" "fmt" "os" "time" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" ) func main() { if len(os.Args) < 5 { fmt.Fprintln(os.Stderr, "usage: adminseed ") os.Exit(1) } dbPath, user, pass, email := os.Args[1], os.Args[2], os.Args[3], os.Args[4] db, err := sql.Open("sqlite", dbPath) if err != nil { fmt.Fprintln(os.Stderr, "open db:", err) os.Exit(1) } defer db.Close() hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) if err != nil { fmt.Fprintln(os.Stderr, "bcrypt:", err) os.Exit(1) } now := time.Now().Unix() var n int _ = db.QueryRow("SELECT COUNT(*) FROM users WHERE username=?", user).Scan(&n) if n > 0 { if _, err = db.Exec("UPDATE users SET password_hash=?, role='admin', email_verified=1 WHERE username=?", string(hash), user); err != nil { fmt.Fprintln(os.Stderr, "update:", err) os.Exit(1) } fmt.Println("已重设管理员密码:", user) return } _, err = db.Exec("INSERT INTO users(username,email,password_hash,role,group_id,balance,level,experience,invite_code,inviter_id,email_verified,context_limit,level_locked_model,banned,register_ip,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", user, email, string(hash), "admin", 1, 0.0, 1, 0, fmt.Sprintf("ADM%d", now%1000000), 0, 1, 100000, "", 0, " ", now) if err != nil { fmt.Fprintln(os.Stderr, "insert:", err) os.Exit(1) } fmt.Println("已创建管理员:", user) } GOEOF go run ./tools/adminseed "$DATA_DIR/zsapi.sqlite" "$ADMIN_USER" "$ADMIN_PASS" "$ADMIN_EMAIL" || die "创建管理员失败" chown -R www-data:www-data "$DATA_DIR" ok "管理员账号已建好" # ---------- 7. 编译前端 ---------- step "7/8 编译前端(第一次要下载 npm 依赖,几分钟)" cd "$FE_DIR" NPM_REG="${NPM_REG:-https://registry.npmmirror.com}" if [ -f package-lock.json ]; then npm ci --registry="$NPM_REG" --no-audit --no-fund || npm install --registry="$NPM_REG" --no-audit --no-fund else npm install --registry="$NPM_REG" --no-audit --no-fund fi npm run build [ -f "$FE_DIR/dist/index.html" ] || die "前端构建产物缺失($FE_DIR/dist/index.html)" chmod -R a+rX /opt/zsapi-vue ok "前端产物:$FE_DIR/dist" # ---------- 8. 服务 + 网站 ---------- step "8/8 注册服务与网站" touch "$LOG_FILE" chown www-data:www-data "$LOG_FILE" cat > /etc/systemd/system/$SVC_NAME.service < /etc/nginx/conf.d/$NGINX_CONF </dev/null 2>&1 || true systemctl restart "$SVC_NAME" nginx -t >/dev/null 2>&1 || die "nginx 配置有误,执行 nginx -t 看详情" systemctl reload nginx 2>/dev/null || systemctl restart nginx sleep 2 if systemctl is-active --quiet "$SVC_NAME"; then ok "后端服务已启动" else warn "后端没起来,看日志:tail -n 40 $LOG_FILE" fi # ---------- HTTPS ---------- if [ -n "$DOMAIN" ] && [ "$LISTEN_PORT" != 80 ]; then warn "非 80 端口部署,跳过 HTTPS 证书(建站请用 80/443)。" elif [ -n "$DOMAIN" ]; then MYPIP=$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true) DIP=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -n1) if [ -n "$MYPIP" ] && [ "$DIP" = "$MYPIP" ]; then step "申请 HTTPS 证书(Let's Encrypt)" apt-get install -y -qq certbot python3-certbot-nginx >/dev/null if certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$LE_EMAIL" --redirect >/dev/null 2>&1; then ok "证书已签发,已自动跳转 HTTPS" else warn "证书没签下来(DNS 没好或 80 端口不通)。之后手动执行:" warn " certbot --nginx -d $DOMAIN -m $LE_EMAIL --agree-tos --redirect" fi else warn "$DOMAIN 还没解析到本机 IP($MYPIP),跳过证书。解析好之后执行:" warn " certbot --nginx -d $DOMAIN -m $LE_EMAIL --agree-tos --redirect" fi fi # ---------- 自检 ---------- step "自检" sleep 1 HEALTH=$(curl -s --max-time 5 "http://127.0.0.1:$SERVER_PORT/api/health" || true) SITE=$(curl -s --max-time 5 "http://127.0.0.1:$SERVER_PORT/api/site-config" || true) CODE_WEB=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${DOMAIN:-127.0.0.1}" "http://127.0.0.1:${LISTEN_PORT}/" || true) info "后端 /api/health -> ${HEALTH:-(无响应)}" info "站点配置 -> ${SITE:0:140}" info "网站首页 HTTP 状态 -> $CODE_WEB" cat <