From a7e0651bd5b21565bb7d21c1eeb2591617fd62c2 Mon Sep 17 00:00:00 2001 From: aiyimickey <39365912+aiyimickey@users.noreply.github.com> Date: Fri, 15 May 2026 09:18:55 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20deploy-ai.sh=20REA?= =?UTF-8?q?DME=20=E4=B8=8E=E9=83=A8=E7=BD=B2=E8=BF=9E=E9=80=9A=E6=80=A7?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=EF=BC=88v2026.05.14=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [新增] deploy-ai-readme.md 完整文档 - [新增] deploy-ai.sh 部署后自动验证 issue→assets API 连通性 --- backup-db.sh | 28 +++ deploy-ai-readme.md | 148 +++++++++++++++ deploy-ai.sh | 409 ++++++++++++++++++++++++++++++++++++++++ deploy-cc.sh | 169 +++++++++++++++++ edit-site-cc.sh | 448 ++++++++++++++++++++++++++++++++++++++++++++ local.sh | 294 +++++++++++++++++++++++++++++ restore-db.sh | 239 +++++++++++++++++++++++ 7 files changed, 1735 insertions(+) create mode 100755 backup-db.sh create mode 100644 deploy-ai-readme.md create mode 100755 deploy-ai.sh create mode 100755 deploy-cc.sh create mode 100755 edit-site-cc.sh create mode 100755 local.sh create mode 100755 restore-db.sh diff --git a/backup-db.sh b/backup-db.sh new file mode 100755 index 0000000..f65d3d1 --- /dev/null +++ b/backup-db.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# 云端数据库一键备份脚本 +# 用法:bash backup-db.sh + +set -e + +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +LOCAL_DIR="/Users/niuniu/programs/docker/db-backups" + +log() { echo "[$(date '+%H:%M:%S')] $1"; } + +log "开始备份云端数据库..." + +# 云端打包 +ssh txjp "mkdir -p /tmp/db-backup && \ + cp /root/docker/assets-ai/data/assets.db /tmp/db-backup/assets-${TIMESTAMP}.db && \ + cp /root/docker/issue-ai/data/issue.db /tmp/db-backup/issue-${TIMESTAMP}.db" + +# 复制到本地 +log "复制到本地..." +scp "txjp:/tmp/db-backup/assets-${TIMESTAMP}.db" "${LOCAL_DIR}/" +scp "txjp:/tmp/db-backup/issue-${TIMESTAMP}.db" "${LOCAL_DIR}/" + +# 清理云端临时文件 +ssh txjp "rm -rf /tmp/db-backup" + +log "备份完成:${TIMESTAMP}" +ls -lh "${LOCAL_DIR}"/assets-${TIMESTAMP}.db "${LOCAL_DIR}"/issue-${TIMESTAMP}.db 2>/dev/null diff --git a/deploy-ai-readme.md b/deploy-ai-readme.md new file mode 100644 index 0000000..f77ea3f --- /dev/null +++ b/deploy-ai-readme.md @@ -0,0 +1,148 @@ +# deploy-ai.sh — tlyq.ai 站点部署脚本 + +## 概述 + +`deploy-ai.sh` 将本地代码部署到 tlyq.ai 云服务器(txjp,IP: 43.133.38.210)。采用"源码上传 → 服务器构建"模式,本地不做生产构建。 + +**核心流程**:打包源码 → 上传至 txjp → 服务器 `npm install` + `npm run build` → 容器重启生效 + +## 用法 + +```bash +bash scripts/deploy-ai.sh [选项] +``` + +| 选项 | 说明 | +|------|------| +| 无参数 | 自动检测:初次构建 or 增量更新(源码快照比对) | +| `--force` | 强制完整构建(跳过快照比对) | +| `--restart` | 仅重启容器(跳过构建) | +| `--init` | 强制初次完整构建 | + +## 站点选择 + +脚本启动后显示交互式菜单: + +| 编号 | 站点 | 容器 | 构建方式 | +|------|------|------|---------| +| 1 | www.tlyq.ai | — | Next.js `output: 'export'` 静态站点 | +| 2 | cloud.tlyq.ai | — | 纯静态 HTML | +| 3 | token.tlyq.ai | — | 纯静态 HTML | +| 4 | issue.tlyq.ai | issue-ai | Next.js standalone(Docker) | +| 5 | assets.tlyq.ai | assets-ai | Next.js standalone(Docker) | +| 6 | oa.tlyq.ai | oa-ai | Next.js standalone(Docker) | + +## 部署流程 + +### 通用步骤 + +1. **链接替换**:`localhost` → 正式域名(www/cloud/token 站点;OA 站点额外替换内部 API 地址) +2. **快照比对**:计算本地源码 MD5,与服务器上次快照对比,无变化则跳过构建 +3. **打包上传**:tar 打包(排除 `node_modules`、`.next`、`.env` 等),scp 上传至 txjp +4. **解压同步**:rsync 同步源码到目标目录 +5. **依赖安装**:服务器 `npm install --prefer-offline` +6. **环境变量**:写入生产环境所需的环境变量到 `.env` +7. **构建**:服务器 `npm run build` +8. **清理**:删除 Alpine musl 残留(sharp、swc) +9. **重启容器**:`docker compose up -d` + `docker compose restart` +10. **快照保存**:保存本次源码 MD5 用于下次比对 + +### issue-ai 专属 + +- 构建前写入 `ASSETS_API_URL=http://assets-ai:3000/api` +- 构建前写入 `NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai` +- **部署后自动验证 issue→assets API 连通性**(见下方) + +### assets-ai 专属 + +- 构建前写入 `NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets` +- 构建前写入 `ISSUE_API_URL=http://issue-ai:3000/api` + +### OA 专属 + +- 替换三类 URL:前端导航卡片(`localhost` → 生产域名)、服务端内部 API(`localhost` → 容器名)、退出登录重定向 + +## 部署后验证 + +### 公共 URL 检查 + +部署完成后检查站点 HTTP 状态码,200/301/307 视为成功。 + +### issue→assets API 连通性检查 + +部署 issue-ai 后自动执行: + +1. 将连通性检查脚本发送到 txjp 宿主机 +2. `docker cp` 到 issue-ai 容器内 +3. 使用容器内的 `ASSETS_API_KEY` 调 `ASSETS_API_URL/assets?pageSize=1` +4. 返回 200 → 通过;返回 401 → **部署失败退出**,提示检查 API Key 配置 + +**错误输出示例**: +``` +[✗] issue→assets API 连通性检查失败!请检查 ASSETS_API_KEY 是否在 assets-ai 中注册 + 连通失败: HTTP 401 (请检查 ASSETS_API_KEY 是否在 assets-ai 的 API Keys 中注册) +``` + +## 排除文件 + +打包时排除以下目录和文件,不上传到服务器: + +| 类别 | 排除项 | +|------|--------| +| 依赖 | `node_modules` | +| 构建产物 | `.next`、`out`、`data` | +| 环境配置 | `.env`、`.env.local`、`.env.development`、`.env.production` | +| 版本控制 | `.git` | +| 文档 | `docs` | +| 报告 | `reports/*.docx` | +| macOS | `._*` | + +部署后服务器上的 `.env.local` / `.env.development` / `.env.production` 会被自动删除,防止覆盖 `.env` 中的生产配置。 + +## 源码快照机制 + +脚本计算本地源码文件的组合 MD5,保存到服务器的 `/tmp/.snapshot.{site}.md5`。下次部署时比对,若无变化则跳过构建仅重启容器。`--force` 可绕过此机制。 + +快照包含:所有源码文件(排除 `node_modules`、`.next`、`out`、`data`、`reports`、`docs`、`.env*`、`.git`)。 + +## 故障排查 + +### 构建失败 +```bash +# 查看服务器构建日志 +ssh txjp "tail -100 /tmp/build_output.txt" +``` + +### 容器启动失败 +```bash +ssh txjp "docker logs {container}" +``` + +### 连通性检查失败 +```bash +# 手动验证 issue→assets 连通性 +ssh txjp "docker exec issue-ai sh -c 'cd /app && NODE_PATH=/app/node_modules node -e \" + const http = require(\\\"http\\\"); + const url = process.env.ASSETS_API_URL + \\\"/assets?pageSize=1\\\"; + const key = process.env.ASSETS_API_KEY || \\\"\\\"; + http.get(url, {headers:{\\\"Authorization\\\":\\\"Bearer \\\"+key}}, (res) => { + console.log(\\\"status:\\\", res.statusCode); + process.exit(res.statusCode === 200 ? 0 : 1); + }).on(\\\"error\\\", (e) => { console.error(e.message); process.exit(1); }); +\"'" + +# 确认 assets-ai 的 api_keys 表中是否有对应的 key +ssh txjp "docker exec assets-ai node -e \" + const db = require('better-sqlite3')('/app/data/assets.db'); + const rows = db.prepare('SELECT id, name, is_active FROM api_keys').all(); + rows.forEach(r => console.log(JSON.stringify(r))); +\"" +``` + +## 注意事项 + +- **跨容器凭据**:禁止在代码或配置中硬编码另一服务的密码,通过运行时读取源容器环境变量获取 +- **共享 JWT 密钥**:所有 `.tlyq.ai` 子站点的 `JWT_SECRET` 和 `COOKIE_DOMAIN` 必须一致 +- **日期时区**:系统统一 UTC+8,禁止使用 `toISOString()` 和 `datetime('now')` +- **nginx 重载**:容器重启后需 `docker restart nginx-ai` 清除 DNS 缓存 +- **新增 npm 依赖**:需重建 Docker 镜像(`docker compose build --no-cache`),因为容器内 `/app/node_modules/` 来自镜像构建时 diff --git a/deploy-ai.sh b/deploy-ai.sh new file mode 100755 index 0000000..2e2f352 --- /dev/null +++ b/deploy-ai.sh @@ -0,0 +1,409 @@ +#!/usr/bin/env bash +# 本地修改 → 上传服务器 → 构建/更新 → 部署 (tlyq.ai) +# +# 策略:服务器上直接 npm run build,复用容器内已安装的 node_modules +# +# 支持系统: +# - macOS (本地): zsh + bash 兼容模式 +# - Ubuntu/Debian (服务器): dash (符号链接到 sh) + bash +# - Rocky Linux/CentOS/RHEL (服务器): bash +# +# 用法:bash deploy-ai.sh [选项] +# 无参数 — 自动检测(初次构建 or 增量更新) +# --force — 强制完整构建 +# --restart — 仅重启容器(跳过构建) +# --init — 强制初次完整构建 + +# 不使用 set -e,改用显式错误处理,避免管道命令导致意外退出 + +# ============================================================ +# 颜色和日志 +# ============================================================ +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m' +log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; } +info() { printf "${CYAN}[i]${NC} %s\n" "$1"; } +error() { printf "${RED}[✗]${NC} %s\n" "$1"; } +timing(){ printf " ${CYAN}⏱${NC} %s\n" "$1"; } + +# ============================================================ +# 检测 macOS 和 Linux sed 差异 +# ============================================================ +is_mac() { + [[ "$(uname)" == "Darwin" ]] +} + +# macOS 的 sed -i 需要空后缀 '',Linux 不需要 +SED_I_BACKUP='-i' +if is_mac; then + SED_I_BACKUP='-i ""' +fi + +# ============================================================ +# 解析参数 +# ============================================================ +FORCE_BUILD=0; ONLY_RESTART=0; INIT_BUILD=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --force) FORCE_BUILD=1; info "模式: 强制完整构建"; shift ;; + --restart) ONLY_RESTART=1; info "模式: 仅重启容器"; shift ;; + --init) INIT_BUILD=1; info "模式: 初次完整构建"; shift ;; + *) warn "未知参数: $1"; shift ;; + esac +done + +# ============================================================ +# 选择站点 +# ============================================================ +echo "" +printf "${CYAN}=========================================${NC}\n" +printf "${CYAN} 选择要部署的站点 (tlyq.ai)${NC}\n" +printf "${CYAN}=========================================${NC}\n" +echo "" +echo " 1) www.tlyq.ai (图灵引擎官网)" +echo " 2) cloud.tlyq.ai (图灵智算系统云平台)" +echo " 3) token.tlyq.ai (Token工厂)" +echo " 4) issue.tlyq.ai (工单系统)" +echo " 5) assets.tlyq.ai (资产管理系统)" +echo " 6) oa.tlyq.ai (OA 统一门户 — 含 nginx 配置)" +echo "" +printf "请输入编号 (1/2/3/4/5/6): " +read choice + +SITE=""; LOCAL_DIR=""; REMOTE_DIR=""; CONTAINER="" +case "$choice" in + 1) SITE="www"; LOCAL_DIR="/Users/niuniu/programs/docker/www-ai/src"; REMOTE_DIR="/tmp/project-extract-2/turing-engine" ;; + 2) SITE="cloud"; LOCAL_DIR="/Users/niuniu/programs/docker/cloud-ai/html"; REMOTE_DIR="/root/docker/cloud-ai/html" ;; + 3) SITE="token"; LOCAL_DIR="/Users/niuniu/programs/docker/token-ai/html"; REMOTE_DIR="/root/docker/token-ai/html" ;; + 4) SITE="issue"; LOCAL_DIR="/Users/niuniu/programs/docker/issue-ai"; REMOTE_DIR="/root/docker/issue-ai"; CONTAINER="issue-ai" ;; + 5) SITE="assets"; LOCAL_DIR="/Users/niuniu/programs/docker/assets-ai"; REMOTE_DIR="/root/docker/assets-ai"; CONTAINER="assets-ai" ;; + 6) SITE="oa"; LOCAL_DIR="/Users/niuniu/programs/docker/oa-ai"; REMOTE_DIR="/root/docker/oa-ai"; CONTAINER="oa-ai" ;; + *) echo "无效选择"; exit 1 ;; +esac + +echo "" +log "部署目标: $LOCAL_DIR → $REMOTE_DIR" +echo "" + +# ============================================================ +# 链接替换 +# ============================================================ +replace_url() { + local mode="$1" + local from to + if [[ "$mode" == "prod" ]]; then + from="localhost"; to="https://www.tlyq.ai" + else + from="https://www.tlyq.ai"; to="localhost" + fi + case "$SITE" in + www) + cd "$LOCAL_DIR" + find src -type f -name '*.tsx' -exec sed $SED_I_BACKUP \ + -e "s|${from}:6174|https://cloud.tlyq.ai|g" \ + -e "s|${from}:6175|https://token.tlyq.ai|g" \ + -e "s|https://cloud.tlyq.ai|${from}:6174|g" \ + -e "s|https://token.tlyq.ai|${from}:6175|g" \ + {} + 2>/dev/null || true ;; + cloud) + sed $SED_I_BACKUP "s|${from}:6173|https://www.tlyq.ai|g" "$LOCAL_DIR/index.html" 2>/dev/null || true + sed $SED_I_BACKUP "s|https://www.tlyq.ai|${from}:6173|g" "$LOCAL_DIR/index.html" 2>/dev/null || true ;; + token) + sed $SED_I_BACKUP "s|${from}:6173|https://www.tlyq.ai|g" "$LOCAL_DIR/index.html" 2>/dev/null || true + sed $SED_I_BACKUP "s|https://www.tlyq.ai|${from}:6173|g" "$LOCAL_DIR/index.html" 2>/dev/null || true ;; + esac +} + +# OA 专属 URL 替换(三类:前端导航 / 服务端内部 API / 退出登录) +replace_oa_urls() { + local mode="$1" # prod | local + cd "$LOCAL_DIR" + if [[ "$mode" == "prod" ]]; then + # A. 前端导航卡片 → 生产域名 + sed $SED_I_BACKUP \ + -e 's|http://localhost:6177|https://assets.tlyq.ai|g' \ + -e 's|http://localhost:6176|https://issue.tlyq.ai|g' \ + -e 's|http://localhost:6173|https://www.tlyq.ai|g' \ + -e 's|http://localhost:6174|https://cloud.tlyq.ai|g' \ + -e 's|http://localhost:6175|https://token.tlyq.ai|g' \ + src/app/page.tsx + # B. 服务端内部 API 调用 → Docker 容器名(assets-ai:3000 / issue-ai:3000) + find src/app/api -name "*.ts" -exec sed $SED_I_BACKUP \ + -e "s|'http://localhost:6177'|'http://assets-ai:3000'|g" \ + -e "s|'http://localhost:6176'|'http://issue-ai:3000'|g" \ + -e "s|\`http://localhost:\${site === 'assets' ? 6177 : 6176}\`|\`http://\${site}-ai:3000\`|g" \ + {} + + # C. 退出登录重定向 + sed $SED_I_BACKUP "s|'http://localhost:6179'|'https://oa.tlyq.ai'|g" src/app/api/auth/logout/route.ts + else + # 恢复本地 URL + sed $SED_I_BACKUP \ + -e 's|https://assets.tlyq.ai|http://localhost:6177|g' \ + -e 's|https://issue.tlyq.ai|http://localhost:6176|g' \ + -e 's|https://www.tlyq.ai|http://localhost:6173|g' \ + -e 's|https://cloud.tlyq.ai|http://localhost:6174|g' \ + -e 's|https://token.tlyq.ai|http://localhost:6175|g' \ + src/app/page.tsx + find src/app/api -name "*.ts" -exec sed $SED_I_BACKUP \ + -e "s|'http://assets-ai:3000'|'http://localhost:6177'|g" \ + -e "s|'http://issue-ai:3000'|'http://localhost:6176'|g" \ + -e "s|\`http://\${site}-ai:3000\`|\`http://localhost:\${site === 'assets' ? 6177 : 6176}\`|g" \ + {} + + sed $SED_I_BACKUP "s|'https://oa.tlyq.ai'|'http://localhost:6179'|g" src/app/api/auth/logout/route.ts + fi +} + +to_prod() { [[ "$SITE" != "issue" && "$SITE" != "assets" && "$SITE" != "oa" ]] && replace_url prod; } +to_local() { [[ "$SITE" != "issue" && "$SITE" != "assets" && "$SITE" != "oa" ]] && replace_url local; } +trap 'to_local; [[ "$SITE" == "oa" ]] && replace_oa_urls local' EXIT +[[ "$SITE" == "oa" ]] && replace_oa_urls prod +[[ "$SITE" != "issue" && "$SITE" != "assets" && "$SITE" != "oa" ]] && to_prod + +# ============================================================ +# 核心构建函数(服务器上直接 npm run build) +# ============================================================ +SNAPSHOT_FILE="/tmp/.snapshot.${SITE}.md5" +BUILD_START=$(date +%s) + +build_on_server() { + local extra_env="${1:-}" + + # 1. 计算源码快照 + log "检查源码是否有变化..." + local_md5=$(find "$LOCAL_DIR" \ + -not -path '*/node_modules/*' \ + -not -path '*/.next/*' \ + -not -path '*/out/*' \ + -not -path '*/data/*' \ + -not -path '*/reports/*' \ + -not -path '*/docs/*' \ + -not -path '*/.env*' \ + -not -path '*/.git/*' \ + -not -name '._*' \ + -type f \ + -exec md5 -q {} \; \ + | sort \ + | md5 -q) + echo " 源码快照: ${local_md5}" + + prev_md5=$(ssh txjp "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "") + + if [[ "$FORCE_BUILD" == "1" || "$INIT_BUILD" == "1" ]]; then + info "强制构建模式" + MODE="force" + elif [[ -z "$prev_md5" ]]; then + info "首次部署,执行完整构建" + MODE="init" + elif [[ "$prev_md5" == "$local_md5" ]]; then + info "源码无变化,跳过构建,仅重启容器" + MODE="skip" + else + info "检测到源码变化,执行增量构建" + MODE="update" + fi + + if [[ "$ONLY_RESTART" == "1" ]]; then + MODE="restart" + info "强制重建容器,跳过构建" + ssh txjp "cd $REMOTE_DIR && docker compose down && docker compose up -d" + return 0 + fi + + if [[ "$MODE" == "skip" || "$MODE" == "restart" ]]; then + ssh txjp "cd $REMOTE_DIR && docker compose down && docker compose up -d" + log "容器已重建" + return 0 + fi + + # 2. 打包源码(不含 node_modules) + log "打包源码..." + pack_start=$(date +%s) + COPYFILE_DISABLE=1 tar czf /tmp/${SITE}.tar.gz -C "$LOCAL_DIR" \ + --exclude='node_modules' --exclude='.next' --exclude='out' \ + --exclude='data' --exclude='./reports/*.docx' --exclude='docs' \ + --exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \ + --exclude='.git' --exclude='._*' . || { + error "打包失败" + return 1 + } + pack_end=$(date +%s) + pack_dur=$((pack_end - pack_start)) + local pkg_size + pkg_size=$(du -h /tmp/${SITE}.tar.gz 2>/dev/null | cut -f1 || echo "未知") + timing "打包耗时: ${pack_dur}s (${pkg_size})" + + # 3. 上传 + log "上传源码包..." + scp /tmp/${SITE}.tar.gz txjp:/tmp/${SITE}.tar.gz || { + error "上传失败" + return 1 + } + + # 4. 解压源码到 /tmp(独立于目标目录,避免 rsync --delete 误删源文件) + log "解压源码并准备依赖..." + ssh txjp "\ + rm -rf /tmp/deploy_${SITE} && mkdir -p /tmp/deploy_${SITE} && \ + tar xzf /tmp/${SITE}.tar.gz -C /tmp/deploy_${SITE} 2>/dev/null || true && \ + rsync -a --delete \ + --exclude='node_modules' --exclude='.next' --exclude='data' --exclude='/reports/*.docx' --exclude='docs' \ + --exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \ + --exclude='._*' \ + /tmp/deploy_${SITE}/ ${REMOTE_DIR}/ && \ + rm -rf /tmp/deploy_${SITE}" + + # 首次:上传 docker-compose.yml(新增 .next 卷挂载),重启容器应用 + ssh txjp "cd $REMOTE_DIR && docker compose up -d" + + # 如果 host 上没有 node_modules,从容器内复制一份 + ssh txjp "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then + echo ' 首次:复制容器内 node_modules 到主机...' + docker cp ${CONTAINER}:/app/node_modules ${REMOTE_DIR}/node_modules + echo ' 完成' + fi" + + # 安装可能新增的依赖(已有依赖走缓存,很快) + log "安装新增依赖..." + ssh txjp "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5 || true" + + if [[ -n "$extra_env" ]]; then + ssh txjp "sed -i 's|ASSETS_API_URL=.*|ASSETS_API_URL=${extra_env}|' $REMOTE_DIR/.env 2>/dev/null \ + || echo 'ASSETS_API_URL=${extra_env}' >> $REMOTE_DIR/.env" + fi + + # assets 站点需要 NEXT_PUBLIC_ISSUE_URL(构建时内嵌到 JS bundle)和 ISSUE_API_URL(运行时,容器内网地址) + if [[ "$SITE" == "assets" ]]; then + ssh txjp "grep -q 'NEXT_PUBLIC_ISSUE_URL' $REMOTE_DIR/.env 2>/dev/null \ + && sed -i 's|NEXT_PUBLIC_ISSUE_URL=.*|NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets|' $REMOTE_DIR/.env 2>/dev/null \ + || echo 'NEXT_PUBLIC_ISSUE_URL=https://issue.tlyq.ai/tickets' >> $REMOTE_DIR/.env" + ssh txjp "grep -q 'ISSUE_API_URL' $REMOTE_DIR/.env 2>/dev/null \ + && sed -i 's|ISSUE_API_URL=.*|ISSUE_API_URL=http://issue-ai:3000/api|' $REMOTE_DIR/.env 2>/dev/null \ + || echo 'ISSUE_API_URL=http://issue-ai:3000/api' >> $REMOTE_DIR/.env" + fi + + # issue 站点需要 NEXT_PUBLIC_ASSETS_URL(构建时内嵌到 JS bundle) + if [[ "$SITE" == "issue" ]]; then + ssh txjp "grep -q 'NEXT_PUBLIC_ASSETS_URL' $REMOTE_DIR/.env 2>/dev/null \ + && sed -i 's|NEXT_PUBLIC_ASSETS_URL=.*|NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai|' $REMOTE_DIR/.env 2>/dev/null \ + || echo 'NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai' >> $REMOTE_DIR/.env" + fi + + # 确保 AUTHELIA_URL 已设置(退出登录 cookie domain 依赖此变量) + ssh txjp "grep -q 'AUTHELIA_URL' $REMOTE_DIR/.env 2>/dev/null \ + && sed -i 's|AUTHELIA_URL=.*|AUTHELIA_URL=https://sso.tlyq.ai|' $REMOTE_DIR/.env 2>/dev/null \ + || echo 'AUTHELIA_URL=https://sso.tlyq.ai' >> $REMOTE_DIR/.env" + # 清理可能被上传的本地环境变量文件(.env.local 等会覆盖 .env 中的生产配置) + ssh txjp "rm -f $REMOTE_DIR/.env.local $REMOTE_DIR/.env.development $REMOTE_DIR/.env.production 2>/dev/null; echo '已清理本地环境文件'" + + # 5. 服务器上直接 npm run build(不重建 Docker) + log "服务器上执行 npm run build..." + build_start=$(date +%s) + # 使用 tee 避免管道导致退出码丢失,并过滤无用输出 + ssh txjp "cd ${REMOTE_DIR} && npm run build 2>&1" | \ + grep -vE "^(info|warn|npm warn|audited|packages|funding|vulnerability|npm notice|New major)" | \ + tee /tmp/build_output.txt | \ + tail -15 + build_exit_code=${PIPESTATUS[0]} + build_end=$(date +%s) + build_dur=$((build_end - build_start)) + timing "构建耗时: ${build_dur}s" + + if [[ "$build_exit_code" -ne 0 ]]; then + warn "构建可能有警告,请检查上面的输出" + fi + + # 清理 standalone 中的 musl 原生模块(Alpine builder 残留,Debian runner 不兼容) + if [[ "$SITE" == "issue" || "$SITE" == "assets" || "$SITE" == "oa" ]]; then + ssh txjp "rm -rf $REMOTE_DIR/.next/standalone/node_modules/@img/sharp-linuxmusl-x64 \ + $REMOTE_DIR/.next/standalone/node_modules/@img/sharp-libvips-linuxmusl-x64 \ + $REMOTE_DIR/.next/standalone/node_modules/@next/swc-linux-x64-musl 2>/dev/null; echo '已清理 musl 残留'" + fi + + # 6. 重建容器(应用 .next 卷挂载;若存在 Dockerfile 则 --build 确保原生模块兼容; + # 无论有无 Dockerfile,restart 确保 Next.js 进程重新加载 .next) + log "重建容器..." + local build_flag="" + ssh txjp "test -f $REMOTE_DIR/Dockerfile" 2>/dev/null && build_flag="--build" + ssh txjp "cd $REMOTE_DIR && docker compose up -d $build_flag && docker compose restart" + + # 7. 保存快照 + ssh txjp "echo '$local_md5' > ${SNAPSHOT_FILE}" +} + +# ============================================================ +# 执行部署 +# ============================================================ +echo "" +case "$SITE" in + www) + build_on_server ;; + cloud) + info "上传静态文件..." + scp "$LOCAL_DIR"/index.html txjp:$REMOTE_DIR/index.html 2>/dev/null || true + ssh txjp "docker restart ${SITE}-ai" ;; + token) + info "上传静态文件..." + scp "$LOCAL_DIR"/index.html txjp:$REMOTE_DIR/index.html 2>/dev/null || true + ssh txjp "docker restart ${SITE}-ai" ;; + issue) + build_on_server "http://assets-ai:3000/api" || exit 1 + info "部署 nginx 路由..." + ssh txjp "cp $REMOTE_DIR/../nginx-proxy-ai/conf.d/issue-ai.conf /root/docker/nginx-proxy-ai/conf.d/ 2>/dev/null || true + docker exec nginx-ai nginx -s reload 2>/dev/null || true" + + # 验证 issue→assets API 连通性(确保 API Key 有效、网络可达) + info "检查 issue→assets API 连通性..." + ssh txjp 'cat > /tmp/check-conn.js' << 'CHECKEOF' +const http = require("http"); +const url = process.env.ASSETS_API_URL + "/assets?pageSize=1"; +const key = process.env.ASSETS_API_KEY || ""; +http.get(url, {headers:{"Authorization":"Bearer "+key}}, (res) => { + if (res.statusCode === 200) { console.log("连通正常:", res.statusCode); process.exit(0); } + console.error("连通失败: HTTP", res.statusCode, "(请检查 ASSETS_API_KEY 是否在 assets-ai 的 API Keys 中注册)"); + process.exit(1); +}).on("error", (e) => { console.error("连通失败:", e.message); process.exit(1); }); +CHECKEOF + CONN_CHECK=$(ssh txjp "docker cp /tmp/check-conn.js issue-ai:/tmp/check-conn.js && docker exec issue-ai sh -c 'cd /app && NODE_PATH=/app/node_modules node /tmp/check-conn.js'" 2>&1) + if [ $? -ne 0 ]; then + error "issue→assets API 连通性检查失败!请检查 ASSETS_API_KEY 是否在 assets-ai 中注册" + echo " $CONN_CHECK" + exit 1 + fi + log "issue→assets API 连通性正常" ;; + assets) + build_on_server || exit 1 + info "部署 nginx 路由..." + ssh txjp "cp $REMOTE_DIR/../nginx-proxy-ai/conf.d/assets-ai.conf /root/docker/nginx-proxy-ai/conf.d/ 2>/dev/null || true + docker exec nginx-ai nginx -s reload 2>/dev/null || true" ;; + oa) + build_on_server || exit 1 + info "部署 nginx 配置..." + ssh txjp "cp $REMOTE_DIR/../nginx-proxy-ai/conf.d/oa-ai.conf /root/docker/nginx-proxy-ai/conf.d/ 2>/dev/null || true + docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" ;; +esac + +# ============================================================ +# 验证 +# ============================================================ +echo "" +log "验证部署..." +BUILD_END=$(date +%s) +TOTAL_DUR=$((BUILD_END - BUILD_START)) + +URL="" +case "$SITE" in + www) URL="https://www.tlyq.ai" ;; + cloud) URL="https://cloud.tlyq.ai" ;; + token) URL="https://token.tlyq.ai" ;; + issue) URL="https://issue.tlyq.ai" ;; + assets) URL="https://assets.tlyq.ai" ;; + oa) URL="https://oa.tlyq.ai" ;; +esac + +STATUS=$(ssh txjp "curl -s -o /dev/null -w '%{http_code}' -k '$URL' 2>/dev/null" 2>/dev/null || echo "???") +if [[ "$STATUS" == "200" || "$STATUS" == "307" || "$STATUS" == "301" ]]; then + log "部署成功!总耗时: ${TOTAL_DUR}s | 访问 $URL" + exit 0 +else + warn "返回状态码: $STATUS,请检查" + exit 1 +fi diff --git a/deploy-cc.sh b/deploy-cc.sh new file mode 100755 index 0000000..760b4ae --- /dev/null +++ b/deploy-cc.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# 本地修改 → 上传服务器 → 构建 → 部署 +# 兼容 macOS (Bash 3.2+) / Ubuntu / Rocky Linux +# 用法:bash deploy-cc.sh + +set -e + +# ANSI 颜色(兼容所有平台的 printf) +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; } + +# ============================================================ +# 选择要部署的站点 +# ============================================================ +echo "" +printf "${CYAN}=========================================${NC}\n" +printf "${CYAN} 选择要部署的站点${NC}\n" +printf "${CYAN}=========================================${NC}\n" +echo "" +echo " 1) www.tlyq.cc (图灵引擎官网)" +echo " 2) cloud.tlyq.cc (图灵智算系统云平台)" +echo " 3) token.tlyq.cc (Token工厂)" +echo "" +printf "请输入编号 (1/2/3): " +read choice + +case "$choice" in + 1) + SITE="www" + LOCAL_DIR="/Users/niuniu/programs/docker/www-cc/src" + REMOTE_DIR="/tmp/project-extract-2/turing-engine" + DEPLOY_DIR="/root/docker/www-cc/html" + ;; + 2) + SITE="cloud" + LOCAL_DIR="/Users/niuniu/programs/docker/cloud-cc/html" + REMOTE_DIR="/root/docker/cloud-cc/html" + DEPLOY_DIR="/root/docker/cloud-cc/html" + ;; + 3) + SITE="token" + LOCAL_DIR="/Users/niuniu/programs/docker/token-cc/html" + REMOTE_DIR="/root/docker/token-cc/html" + DEPLOY_DIR="/root/docker/token-cc/html" + ;; + *) + echo "无效选择,已取消" + exit 1 + ;; +esac + +echo "" +log "部署目标: $LOCAL_DIR → 服务器 $DEPLOY_DIR" +echo "" + +# ============================================================ +# 部署前:将 localhost 链接替换为正式域名 +# ============================================================ +restore_files() { + # 恢复本地文件中的 localhost 链接(deploy 后 / 异常退出时) + case "$SITE" in + www) + cd "$LOCAL_DIR" + find src -type f -name '*.tsx' -exec sed -i '' \ + -e 's|http://localhost:5174/login|https://cloud.tlyq.cc/login|g' \ + -e 's|http://localhost:5175/login|https://token.tlyq.cc/login|g' \ + {} + + ;; + cloud) + sed -i '' 's|http://localhost:5173|https://www.tlyq.cc|g' "$LOCAL_DIR/index.html" + ;; + token) + sed -i '' 's|http://localhost:5173|https://www.tlyq.cc|g' "$LOCAL_DIR/index.html" + ;; + esac +} + +to_production() { + log "将 localhost 链接替换为正式域名..." + case "$SITE" in + www) + cd "$LOCAL_DIR" + find src -type f -name '*.tsx' -exec sed -i '' \ + -e 's|http://localhost:5174/login|https://cloud.tlyq.cc/login|g' \ + -e 's|http://localhost:5175/login|https://token.tlyq.cc/login|g' \ + {} + + ;; + cloud) + sed -i '' 's|http://localhost:5173|https://www.tlyq.cc|g' "$LOCAL_DIR/index.html" + ;; + token) + sed -i '' 's|http://localhost:5173|https://www.tlyq.cc|g' "$LOCAL_DIR/index.html" + ;; + esac + log "域名替换完成" +} + +to_localhost() { + log "恢复 localhost 链接..." + case "$SITE" in + www) + cd "$LOCAL_DIR" + find src -type f -name '*.tsx' -exec sed -i '' \ + -e 's|https://cloud.tlyq.cc/login|http://localhost:5174/login|g' \ + -e 's|https://token.tlyq.cc/login|http://localhost:5175/login|g' \ + {} + + ;; + cloud) + sed -i '' 's|https://www.tlyq.cc|http://localhost:5173|g' "$LOCAL_DIR/index.html" + ;; + token) + sed -i '' 's|https://www.tlyq.cc|http://localhost:5173|g' "$LOCAL_DIR/index.html" + ;; + esac + log "已恢复为 localhost 链接" +} + +# 确保无论成功或失败都恢复本地文件 +trap 'to_localhost' EXIT + +to_production + +# ============================================================ +# www 站点需要构建,cloud/token 是静态文件直接上传 +# ============================================================ +if [ "$SITE" = "www" ]; then + # 打包源码 + log "打包源码..." + cd "$LOCAL_DIR" + COPYFILE_DISABLE=1 tar czf /tmp/www-src.tar.gz --exclude='node_modules' --exclude='.next' --exclude='out' . + + # 上传 + log "上传到服务器..." + scp /tmp/www-src.tar.gz tgz:/tmp/www-src.tar.gz + + # 服务器上构建并部署 + log "服务器上构建并部署..." + ssh tgz "cd $REMOTE_DIR && rm -rf .next out src && tar xzf /tmp/www-src.tar.gz && npm install && rm -rf .next out && npm run build && rm -rf ${DEPLOY_DIR:?}/* && cp -r out/* $DEPLOY_DIR/ && echo 'deployed'" +else + # cloud/token 直接上传静态文件 + log "上传静态文件..." + scp "$LOCAL_DIR"/index.html tgz:$DEPLOY_DIR/index.html + scp "$LOCAL_DIR"/nginx.conf tgz:$DEPLOY_DIR/nginx.conf + + # 重启容器 + log "重启容器..." + ssh tgz "docker restart ${SITE}-cc" +fi + +# 验证 +echo "" +log "验证部署..." +case "$SITE" in + www) URL="https://www.tlyq.cc" ;; + cloud) URL="https://cloud.tlyq.cc" ;; + token) URL="https://token.tlyq.cc" ;; +esac + +STATUS=$(ssh tgz "curl -s -o /dev/null -w '%{http_code}' -k '$URL'") +if [ "$STATUS" = "200" ]; then + log "部署成功!访问 $URL" +else + warn "返回状态码: $STATUS,请检查" +fi diff --git a/edit-site-cc.sh b/edit-site-cc.sh new file mode 100755 index 0000000..d0a3da9 --- /dev/null +++ b/edit-site-cc.sh @@ -0,0 +1,448 @@ +#!/bin/bash +# www.tlyq.cc 内容管理脚本 +# 兼容 macOS (Bash 3.2+) / Ubuntu / Rocky Linux +# 用法:bash edit-site-cc.sh + +set -e + +# ============================================================ +# 配置 +# ============================================================ +SERVER="tgz" +SRC_DIR="/tmp/project-extract-2/turing-engine" +TRANS="src/lib/translations.ts" +FOOTER="src/components/Footer.tsx" +CONTACT="src/app/contact/page.tsx" +NEWS="src/app/news/page.tsx" + +# ANSI 颜色 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; } +err() { printf "${RED}[✗]${NC} %s\n" "$1"; } +info() { printf "${CYAN}[i]${NC} %s\n" "$1"; } + +# 在服务器上执行命令 +rsh() { ssh "$SERVER" "$@"; } + +# 获取 translations.ts 中指定 marker 的行号 +# 用法: get_line "marker" [nth] nth=1 返回第1个匹配,nth=2 返回第2个 +get_line() { + local marker="$1" + local nth="${2:-1}" + rsh "grep -n \"$marker\" $SRC_DIR/$TRANS | sed -n '${nth}p' | cut -d: -f1" +} + +# 在服务器文件中批量插入多行 +# 用法: insert_lines ... +# 行号从大到小排序后依次插入,保证行号准确性 +insert_lines() { + local args=("$@") + local pairs="" + local i=0 + while [ $i -lt $# ]; do + local ln="${args[$i]}" + local ct="${args[$((i+1))]}" + # 构造 python 元组列表: (line_num, content) + if [ -n "$pairs" ]; then + pairs="$pairs," + fi + # 转义内容中的反斜杠和引号 + pairs="$pairs($ln, \"$(printf '%s' "$ct" | sed 's/\\/\\\\/g; s/"/\\"/g')\")" + i=$((i+2)) + done + ssh "$SERVER" python3 - <${ZH_MS_LINE} && /date:.*title:/{printf \" %d: %s\\n\", NR, \$0}' $SRC_DIR/$TRANS" + + echo "" + printf "请输入要修改的条目行号: " + read LINE_NUM + [ -z "$LINE_NUM" ] && { err "行号不能为空"; return; } + + # 验证行号是否有效 + VALID=$(rsh "sed -n '${LINE_NUM}p' $SRC_DIR/$TRANS | grep -c 'date:.*title:' || true") + if [ "$VALID" != "1" ]; then + err "该行不是有效的历程条目" + return + fi + + # 计算对应的英文行号 + OFFSET=$((LINE_NUM - ZH_MS_LINE)) + EN_LINE_NUM=$((EN_MS_LINE + OFFSET)) + + # 显示当前值 + info "当前中文内容:" + rsh "sed -n '${LINE_NUM}p' $SRC_DIR/$TRANS" + info "当前英文内容:" + rsh "sed -n '${EN_LINE_NUM}p' $SRC_DIR/$TRANS" + + echo "" + printf "${YELLOW}--- 新的中文信息 ---${NC}\n" + printf "日期: " + read ZH_DATE + [ -z "$ZH_DATE" ] && { err "日期不能为空"; return; } + printf "标题: " + read ZH_TITLE + [ -z "$ZH_TITLE" ] && { err "标题不能为空"; return; } + printf "描述: " + read ZH_DESC + [ -z "$ZH_DESC" ] && { err "描述不能为空"; return; } + + echo "" + printf "${YELLOW}--- New English Info ---${NC}\n" + printf "Date: " + read EN_DATE + [ -z "$EN_DATE" ] && { err "Date is required"; return; } + printf "Title: " + read EN_TITLE + [ -z "$EN_TITLE" ] && { err "Title is required"; return; } + printf "Description: " + read EN_DESC + [ -z "$EN_DESC" ] && { err "Description is required"; return; } + + echo "" + printf "确认修改? (y/n): " + read confirm + [ "$confirm" != "y" ] && { warn "已取消"; return; } + + # 替换中文行 + ZH_NEW=" { date: '$ZH_DATE', title: '$ZH_TITLE', desc: '$ZH_DESC' }," + # 转义替换内容中的特殊字符 + ZH_NEW_ESC=$(printf '%s' "$ZH_NEW" | sed 's/[&/\\]/\\&/g') + rsh "sed -i '${LINE_NUM}s/.*/${ZH_NEW_ESC}/' $SRC_DIR/$TRANS" + + # 替换英文行 + EN_NEW=" { date: '$EN_DATE', title: '$EN_TITLE', desc: '$EN_DESC' }," + EN_NEW_ESC=$(printf '%s' "$EN_NEW" | sed 's/[&/\\]/\\&/g') + rsh "sed -i '${EN_LINE_NUM}s/.*/${EN_NEW_ESC}/' $SRC_DIR/$TRANS" + + log "发展历程已更新(中英文)" + ask_deploy +} + +# ============================================================ +# 功能 4:部署到服务器 +# ============================================================ +deploy() { + printf "${CYAN}--- 部署 www.tlyq.cc ---${NC}\n" + echo "" + + LOCAL_DIR="/Users/niuniu/programs/docker/www-cc/src" + REMOTE_DIR="$SRC_DIR" + DEPLOY_DIR="/root/docker/www-cc/html" + + # 从服务器同步最新源码到本地 + info "从服务器同步最新源码..." + rsync -az --delete --exclude='node_modules' --exclude='.next' --exclude='out' \ + "$SERVER:$REMOTE_DIR/" "$LOCAL_DIR/" 2>/dev/null || { + warn "rsync 不可用,使用 scp..." + rm -rf "$LOCAL_DIR" + mkdir -p "$LOCAL_DIR" + scp -r "$SERVER:$REMOTE_DIR/." "$LOCAL_DIR/" 2>/dev/null + rm -rf "$LOCAL_DIR/node_modules" "$LOCAL_DIR/.next" "$LOCAL_DIR/out" + } + + log "打包源码..." + cd "$LOCAL_DIR" + COPYFILE_DISABLE=1 tar czf /tmp/www-src.tar.gz --exclude='node_modules' --exclude='.next' --exclude='out' . + + log "上传到服务器..." + scp /tmp/www-src.tar.gz "$SERVER:/tmp/www-src.tar.gz" + + log "服务器上构建并部署..." + ssh "$SERVER" "cd $REMOTE_DIR && rm -rf .next out src && tar xzf /tmp/www-src.tar.gz && npm install && rm -rf .next out && npm run build && rm -rf ${DEPLOY_DIR:?}/* && cp -r out/* $DEPLOY_DIR/" + + echo "" + log "验证部署..." + URL="https://www.tlyq.cc" + STATUS=$(ssh "$SERVER" "curl -s -o /dev/null -w '%{http_code}' -k '$URL'") + if [ "$STATUS" = "200" ]; then + log "部署成功!访问 $URL" + else + warn "返回状态码: $STATUS,请检查" + fi + + echo "" + show_menu +} + +# ============================================================ +# 通用:询问是否部署 +# ============================================================ +ask_deploy() { + echo "" + printf "是否立即部署到服务器? (y/n): " + read d + if [ "$d" = "y" ]; then + deploy + else + warn "未部署,修改仅在服务器源码中生效" + info "稍后可选择菜单 4) 部署到服务器" + show_menu + fi +} + +# ============================================================ +# 启动 +# ============================================================ +show_menu diff --git a/local.sh b/local.sh new file mode 100755 index 0000000..72d7cfe --- /dev/null +++ b/local.sh @@ -0,0 +1,294 @@ +#!/bin/bash +# 本地站点管理:启动 / 停止 / 重启 / 状态 +# 用法:bash local.sh [start|stop|restart|status] [站点名...] +# 不加参数则交互选择 +# +# 站点名: +# cc: www-cc cloud-cc token-cc +# ai: www-ai cloud-ai token-ai issue assets ldap +# all: 全部站点 + +set -e + +BASE_DIR="/Users/niuniu/programs/docker" +ISSUE_DIR="$BASE_DIR/issue-ai" +ASSETS_DIR="$BASE_DIR/assets-ai" +LDAP_DIR="$BASE_DIR/ldap-ai" +OA_DIR="$BASE_DIR/oa-ai" +OA_PORT=6179 + +# 颜色 +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m' +log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; } +err() { printf "${RED}[✗]${NC} %s\n" "$1"; } + +# 端口 +ISSUE_PORT=6176 +ASSETS_PORT=6177 +LDAP_WEB_PORT=6178 + +is_port() { lsof -i :"$1" >/dev/null 2>&1; } +is_running() { docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^$1$"; } + +# ============================================================ +# 单个 Docker 站点操作 +# ============================================================ +docker_start() { + local name=$1 dir=$2 + if is_running $name; then + warn "$name 已在运行" + else + log "启动 $name..." + cd "$dir" && docker compose up -d 2>/dev/null && log "$name 已启动" || err "$name 启动失败" + fi +} +docker_stop() { + local name=$1 dir=$2 + if is_running $name; then + log "停止 $name..." + cd "$dir" && docker compose down 2>/dev/null && log "$name 已停止" || err "$name 停止失败" + else + warn "$name 未在运行" + fi +} + +# ============================================================ +# 单个 Node.js 服务(npm run dev) +# ============================================================ +node_start() { + local name=$1 dir=$2 port=$3 logfile=$4 + if is_port $port; then + warn "$name 已在运行 (port $port)" + else + log "启动 $name (port $port)..." + cd "$dir" && npm run dev > "$logfile" 2>&1 & + sleep 2 + is_port $port && log "$name 已启动" || err "$name 启动失败,请查看 $logfile" + fi +} +node_stop() { + local name=$1 port=$2 + if is_port $port; then + kill $(lsof -ti :$port) 2>/dev/null && log "$name 已停止" || warn "$name 停止失败" + else + warn "$name 未在运行" + fi +} + +# ============================================================ +# 批量操作 +# ============================================================ +start_all() { + for name in "$@"; do + case $name in + www-cc) docker_start www-local "$BASE_DIR/www-cc" ;; + cloud-cc) docker_start cloud-cc "$BASE_DIR/cloud-cc" ;; + token-cc) docker_start token-cc "$BASE_DIR/token-cc" ;; + www-ai) docker_start www-local-ai "$BASE_DIR/www-ai" ;; + cloud-ai) docker_start cloud-ai "$BASE_DIR/cloud-ai" ;; + token-ai) docker_start token-ai "$BASE_DIR/token-ai" ;; + issue) node_start issue-ai "$ISSUE_DIR" $ISSUE_PORT "/tmp/issue-ai-dev.log" + if ! is_port $ASSETS_PORT; then + node_start assets-ai "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log" + warn "已自动启动 assets-ai(issue 依赖 assets API)" + fi ;; + assets) node_start assets-ai "$ASSETS_DIR" $ASSETS_PORT "/tmp/assets-ai-dev.log" ;; + ldap) docker_start lldap "$LDAP_DIR" + if is_port $LDAP_WEB_PORT; then + log "LLDAP Web UI: http://localhost:$LDAP_WEB_PORT" + fi ;; + oa) node_start oa-ai "$OA_DIR" $OA_PORT "/tmp/oa-ai-dev.log" ;; + cc) start_all www-cc cloud-cc token-cc ;; + ai) start_all ldap www-ai cloud-ai token-ai issue assets oa ;; + all) start_all cc ai ;; + *) err "未知站点: $name" ;; + esac + done +} + +stop_all() { + for name in "$@"; do + case $name in + www-cc) docker_stop www-local "$BASE_DIR/www-cc" ;; + cloud-cc) docker_stop cloud-cc "$BASE_DIR/cloud-cc" ;; + token-cc) docker_stop token-cc "$BASE_DIR/token-cc" ;; + www-ai) docker_stop www-local-ai "$BASE_DIR/www-ai" ;; + cloud-ai) docker_stop cloud-ai "$BASE_DIR/cloud-ai" ;; + token-ai) docker_stop token-ai "$BASE_DIR/token-ai" ;; + issue) node_stop issue-ai $ISSUE_PORT ;; + assets) node_stop assets-ai $ASSETS_PORT ;; + ldap) docker_stop lldap "$LDAP_DIR" ;; + oa) node_stop oa-ai $OA_PORT ;; + cc) stop_all www-cc cloud-cc token-cc ;; + ai) stop_all ldap www-ai cloud-ai token-ai issue assets oa ;; + all) stop_all cc ai ;; + *) err "未知站点: $name" ;; + esac + done +} + +restart_all() { + stop_all "$@" + echo "" + log "正在重启..." + start_all "$@" +} + +# ============================================================ +# 状态 +# ============================================================ +show_status() { + local targets=("$@") + # 合并 all/cc/ai + local expanded=() + for t in "${targets[@]}"; do + case $t in + all) eval "expanded=(www-cc cloud-cc token-cc ldap www-ai cloud-ai token-ai issue assets oa)" ;; + cc) eval "expanded=(\${expanded[@]} www-cc cloud-cc token-cc)" ;; + ai) eval "expanded=(\${expanded[@]} ldap www-ai cloud-ai token-ai issue assets oa)" ;; + *) expanded+=($t) ;; + esac + done + + echo "" + printf "${CYAN}=========================================${NC}\n" + printf "${CYAN} 当前状态${NC}\n" + printf "${CYAN}=========================================${NC}\n" + echo "" + + local running=0 + for name in "${expanded[@]}"; do + case $name in + www-cc|cloud-cc|token-cc|cloud-ai|token-ai) + if is_running $name; then + printf " ${GREEN}●${NC} %-12s 运行中\n" "$name" + running=$((running + 1)) + else + printf " ${RED}●${NC} %-12s 未运行\n" "$name" + fi + ;; + www-ai) + if is_running www-local-ai; then + printf " ${GREEN}●${NC} %-12s 运行中\n" "$name" + running=$((running + 1)) + else + printf " ${RED}●${NC} %-12s 未运行\n" "$name" + fi + ;; + issue) + if is_port $ISSUE_PORT; then + printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $ISSUE_PORT + running=$((running + 1)) + else + printf " ${RED}●${NC} %-12s 未运行\n" "$name" + fi + ;; + ldap) + if is_running lldap; then + printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $LDAP_WEB_PORT + running=$((running + 1)) + else + printf " ${RED}●${NC} %-12s 未运行\n" "$name" + fi + ;; + assets) + if is_port $ASSETS_PORT; then + printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $ASSETS_PORT + running=$((running + 1)) + else + printf " ${RED}●${NC} %-12s 未运行\n" "$name" + fi + ;; + oa) + if is_port $OA_PORT; then + printf " ${GREEN}●${NC} %-12s 运行中 (port %d)\n" "$name" $OA_PORT + running=$((running + 1)) + else + printf " ${RED}●${NC} %-12s 未运行\n" "$name" + fi + ;; + esac + done + echo "" + + printf "${CYAN}=========================================${NC}\n" + printf "${CYAN} 访问地址${NC}\n" + printf "${CYAN}=========================================${NC}\n" + echo "" + echo " cc 站点:" + echo " www-cc http://localhost:5173" + echo " cloud-cc http://localhost:5174" + echo " token-cc http://localhost:5175" + echo "" + echo " ai 站点:" + echo " www-ai http://localhost:6173" + echo " cloud-ai http://localhost:6174" + echo " token-ai http://localhost:6175" + echo " ldap http://localhost:$LDAP_WEB_PORT (LLDAP Web UI)" + echo " oa http://localhost:$OA_PORT" + echo " issue http://localhost:$ISSUE_PORT" + echo " assets http://localhost:$ASSETS_PORT" + echo "" +} + +# ============================================================ +# 帮助 +# ============================================================ +show_help() { + echo "" + printf "${CYAN}用法:${NC} bash local.sh <命令> [站点名...]\n" + echo "" + echo " 命令:" + echo " start 启动站点" + echo " stop 停止站点" + echo " restart 重启站点" + echo " status 查看状态" + echo "" + echo " 站点名(可指定多个,空格分隔):" + echo " www-cc cloud-cc token-cc" + echo " ldap www-ai cloud-ai token-ai issue assets oa" + echo " cc — tlyq.cc 全部 (www-cc cloud-cc token-cc)" + echo " ai — tlyq.ai 全部 (ldap www-ai cloud-ai token-ai issue assets oa)" + echo " all — 全部站点" + echo "" + echo "示例:" + echo " bash local.sh start all # 启动全部" + echo " bash local.sh start ai # 启动 ai 全部" + echo " bash local.sh start issue assets # 只启 issue + assets" + echo " bash local.sh stop www-ai # 只停 www-ai" + echo " bash local.sh status # 查看所有状态" + echo "" +} + +# ============================================================ +# 入口 +# ============================================================ +ACTION=${1:-} +shift || true + +if [ -z "$ACTION" ]; then + show_help + exit 0 +fi + +# 解析站点列表 +if [ $# -eq 0 ]; then + case $ACTION in + start|stop|restart) TARGETS=(all) ;; + status) TARGETS=(all) ;; + help|--help|-h) show_help; exit 0 ;; + *) err "未知命令: $ACTION"; show_help; exit 1 ;; + esac +else + TARGETS=("$@") +fi + +case $ACTION in + start) start_all "${TARGETS[@]}" ;; + stop) stop_all "${TARGETS[@]}" ;; + restart) restart_all "${TARGETS[@]}" ;; + status) show_status "${TARGETS[@]}" ;; + help|--help|-h) show_help ;; + *) err "未知命令: $ACTION"; show_help; exit 1 ;; +esac diff --git a/restore-db.sh b/restore-db.sh new file mode 100755 index 0000000..e3c0e5e --- /dev/null +++ b/restore-db.sh @@ -0,0 +1,239 @@ +#!/bin/bash +# ============================================ +# 数据库恢复脚本 +# +# 用法: +# 恢复本地数据库: bash scripts/restore-db.sh local <服务名> <备份文件名> +# 恢复云端数据库: bash scripts/restore-db.sh cloud <服务名> <备份文件名> +# +# 服务名: issue / assets +# +# 示例: +# bash scripts/restore-db.sh local issue issue-2026-04-29_1753.db +# bash scripts/restore-db.sh cloud assets assets-2026-04-29_1753.db +# ============================================ + +set -e + +# ---- 参数检查 ---- +if [ $# -lt 3 ]; then + echo "用法: $0 <备份文件名>" + echo "" + echo "示例:" + echo " $0 local issue issue-2026-04-29_1753.db" + echo " $0 cloud assets assets-2026-04-29_1753.db" + echo "" + echo "可用备份文件:" + echo " 本地:" + ls -1 /Users/niuniu/programs/docker/db-backups/ 2>/dev/null || echo " (无)" + echo " 云端:" + ssh txjp "ls -1 /root/docker/db-backups/" 2>/dev/null || echo " (无)" + exit 1 +fi + +TARGET=$1 +SERVICE=$2 +BACKUP_FILE=$3 + +# ---- 路径定义 ---- +LOCAL_BACKUP_DIR="/Users/niuniu/programs/docker/db-backups" +CLOUD_BACKUP_DIR="/root/docker/db-backups" + +case $SERVICE in + issue) + PROJECT_DIR="issue-ai" + CONTAINER="issue-ai" + DB_PATH_IN_CONTAINER="/app/data/issue.db" + LOCAL_DB_PATH="/Users/niuniu/programs/docker/issue-ai/data/issue.db" + ;; + assets) + PROJECT_DIR="assets-ai" + CONTAINER="assets-ai" + DB_PATH_IN_CONTAINER="/app/data/assets.db" + LOCAL_DB_PATH="/Users/niuniu/programs/docker/assets-ai/data/assets.db" + ;; + *) + echo "错误: 服务名必须是 issue 或 assets" + exit 1 + ;; +esac + +# ============================================ +# 本地恢复 +# ============================================ +restore_local() { + local BACKUP_PATH="${LOCAL_BACKUP_DIR}/${BACKUP_FILE}" + + echo "========================================" + echo " 本地数据库恢复" + echo "========================================" + echo " 服务: ${SERVICE} (${CONTAINER})" + echo " 备份文件: ${BACKUP_PATH}" + echo " 目标数据库: ${LOCAL_DB_PATH}" + echo "========================================" + echo "" + + # 1. 检查备份文件 + if [ ! -f "$BACKUP_PATH" ]; then + echo "[错误] 备份文件不存在: ${BACKUP_PATH}" + exit 1 + fi + + # 2. 验证备份文件是有效的 SQLite 数据库 + if ! sqlite3 "$BACKUP_PATH" "SELECT count(*) FROM sqlite_master" > /dev/null 2>&1; then + echo "[错误] 备份文件不是有效的 SQLite 数据库" + exit 1 + fi + + # 3. 确认操作 + echo "[警告] 这将覆盖本地 ${CONTAINER} 数据库!" + echo " 当前数据库: ${LOCAL_DB_PATH}" + echo " 将替换为: ${BACKUP_PATH}" + echo "" + read -p "确认恢复? (输入 yes 继续): " CONFIRM + if [ "$CONFIRM" != "yes" ]; then + echo "已取消" + exit 0 + fi + + # 4. 备份当前数据库(安全起见) + local SAFE_BACKUP="${LOCAL_DB_PATH}.before-restore-$(date +%Y%m%d_%H%M%S)" + if [ -f "$LOCAL_DB_PATH" ]; then + cp "$LOCAL_DB_PATH" "$SAFE_BACKUP" + echo "[备份] 当前数据库已备份至: ${SAFE_BACKUP}" + fi + + # 5. WAL checkpoint on backup (确保数据完整) + sqlite3 "$BACKUP_PATH" "PRAGMA wal_checkpoint(TRUNCATE)" 2>/dev/null || true + + # 6. 停止本地容器(如果在运行) + if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + echo "[操作] 停止容器 ${CONTAINER}..." + docker stop "$CONTAINER" + fi + + # 7. 替换数据库 + cp "$BACKUP_PATH" "$LOCAL_DB_PATH" + rm -f "${LOCAL_DB_PATH}-shm" "${LOCAL_DB_PATH}-wal" + echo "[完成] 数据库已替换" + + # 8. 启动容器 + echo "[操作] 启动容器 ${CONTAINER}..." + docker start "$CONTAINER" + + # 9. 验证 + sleep 2 + echo "" + echo "[验证] 恢复后数据:" + if [ "$SERVICE" = "issue" ]; then + echo " 工单数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM tickets")" + echo " 配件名称: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM tickets WHERE parts_name IS NOT NULL AND parts_name != ''") 条" + else + echo " 设备数: $(sqlite3 "$LOCAL_DB_PATH" "SELECT COUNT(*) FROM assets")" + fi + echo "" + echo "恢复完成!" +} + +# ============================================ +# 云端恢复 +# ============================================ +restore_cloud() { + local BACKUP_PATH="${CLOUD_BACKUP_DIR}/${BACKUP_FILE}" + + echo "========================================" + echo " 云端数据库恢复 (txjp)" + echo "========================================" + echo " 服务: ${SERVICE} (${CONTAINER})" + echo " 备份文件: ${BACKUP_PATH}" + echo "========================================" + echo "" + + # 1. 检查备份文件 + if ! ssh txjp "test -f ${BACKUP_PATH}"; then + echo "[错误] 云端备份文件不存在: ${BACKUP_PATH}" + exit 1 + fi + + # 2. 验证备份文件 + if ! ssh txjp "sqlite3 ${BACKUP_PATH} 'SELECT count(*) FROM sqlite_master' > /dev/null 2>&1"; then + echo "[错误] 备份文件不是有效的 SQLite 数据库" + exit 1 + fi + + # 3. 显示备份和当前状态 + echo "[信息] 备份文件信息:" + ssh txjp "ls -lh ${BACKUP_PATH}" + echo "" + echo "[信息] 当前云端数据库状态:" + if [ "$SERVICE" = "issue" ]; then + ssh txjp "docker exec ${CONTAINER} node -e \" + const D=require('better-sqlite3'); + const db=new D('/app/data/issue.db',{readonly:true}); + console.log(' 工单数:', db.prepare('SELECT COUNT(*) as c FROM tickets').get().c); + db.close(); + \"" + else + ssh txjp "docker exec ${CONTAINER} node -e \" + const D=require('better-sqlite3'); + const db=new D('/app/data/assets.db',{readonly:true}); + console.log(' 设备数:', db.prepare('SELECT COUNT(*) as c FROM assets').get().c); + db.close(); + \"" + fi + + # 4. 确认 + echo "" + echo "[警告] 这将覆盖云端 ${CONTAINER} 数据库!" + read -p "确认恢复? (输入 yes 继续): " CONFIRM + if [ "$CONFIRM" != "yes" ]; then + echo "已取消" + exit 0 + fi + + # 5. 云端安全备份当前数据库 + local SAFE_NAME="before-restore-$(date +%Y%m%d_%H%M%S).db" + echo "[操作] 备份云端当前数据库..." + ssh txjp "docker cp ${CONTAINER}:${DB_PATH_IN_CONTAINER} /root/docker/db-backups/${SAFE_NAME}" + echo "[备份] 当前数据库已保存至云端: /root/docker/db-backups/${SAFE_NAME}" + + # 6. 复制备份文件到容器 + echo "[操作] 复制备份文件到容器..." + ssh txjp "docker cp ${BACKUP_PATH} ${CONTAINER}:${DB_PATH_IN_CONTAINER}" + + # 7. 重启容器 + echo "[操作] 重启容器 ${CONTAINER}..." + ssh txjp "cd /root/docker/${PROJECT_DIR} && docker compose restart ${CONTAINER}" + + # 8. 验证 + sleep 3 + echo "" + echo "[验证] 恢复后数据:" + if [ "$SERVICE" = "issue" ]; then + ssh txjp "docker exec ${CONTAINER} node -e \" + const D=require('better-sqlite3'); + const db=new D('/app/data/issue.db',{readonly:true}); + console.log(' 工单数:', db.prepare('SELECT COUNT(*) as c FROM tickets').get().c); + db.close(); + \"" + else + ssh txjp "docker exec ${CONTAINER} node -e \" + const D=require('better-sqlite3'); + const db=new D('/app/data/assets.db',{readonly:true}); + console.log(' 设备数:', db.prepare('SELECT COUNT(*) as c FROM assets').get().c); + db.close(); + \"" + fi + echo "" + echo "恢复完成!" +} + +# ---- 执行 ---- +case $TARGET in + local) restore_local ;; + cloud) restore_cloud ;; + *) + echo "错误: 目标必须是 local 或 cloud" + exit 1 + ;; +esac