From e18040641efd0751f11b6856a4927b0a0238e920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E9=98=B3=20=E9=BE=9A?= <2302635277@qq.com> Date: Thu, 16 Jul 2026 09:02:53 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20Docker=20CI/CD?= =?UTF-8?q?=20=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/CI-CD-DESIGN.md | 242 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 deploy/CI-CD-DESIGN.md diff --git a/deploy/CI-CD-DESIGN.md b/deploy/CI-CD-DESIGN.md new file mode 100644 index 0000000..500cd12 --- /dev/null +++ b/deploy/CI-CD-DESIGN.md @@ -0,0 +1,242 @@ +# Solvify-Agent CI/CD 设计 + +## 1. 背景 + +当前仓库只有基础 Go 测试工作流和一份不可直接运行的 Compose 示例,缺少前后端生产镜像、完整服务编排、远程部署、健康检查和失败回滚能力。本设计面向一台 Linux 生产服务器,将 Vue 前端、Go 后端、PostgreSQL(pgvector)和 Redis 全部交由 Docker Compose 管理,并在代码进入 `main` 后自动部署。 + +## 2. 目标 + +- Pull Request 合入前验证 Go 和 Vue 项目 +- `main` 分支更新后构建并发布前后端生产镜像 +- 通过 SSH 将指定提交版本部署到单台 Linux 服务器 +- 使用 Docker Compose 编排前端、后端、PostgreSQL 和 Redis +- 持久化数据库、缓存、上传文件和日志 +- 部署完成后自动执行健康检查,失败时恢复上一应用版本 +- 所有密钥通过 GitHub Secrets 或服务器本地受限文件注入 + +## 3. 非目标 + +- 不配置域名、HTTPS、CDN 或负载均衡 +- 不实现多服务器集群、Docker Swarm 或 Kubernetes +- 不承诺零停机更新 +- 不自动回滚数据库结构 +- 不向生产数据库导入 `scripts/seed_interface_data.sql` 中的接口测试数据 + +## 4. 最终架构 + +生产服务器只对外开放 TCP `80`。Compose 内部使用独立网络连接以下服务: + +| 服务 | 镜像 | 职责 | 对外端口 | +| --- | --- | --- | --- | +| `frontend` | GHCR 前端镜像 | Nginx 托管 Vue 静态资源,并反向代理 `/api/` 和 `/health` | `80` | +| `backend` | GHCR 后端镜像 | 运行 Go API,并提供 Python 文档解析能力 | 无 | +| `postgres` | pgvector PostgreSQL 官方镜像 | 持久化业务数据和向量索引 | 无 | +| `redis` | Redis 官方镜像 | 缓存、验证码和 Token 黑名单 | 无 | +| `migrate` | pgvector PostgreSQL 官方镜像 | 部署时一次性执行幂等数据库脚本 | 无 | + +`migrate` 是一次性任务,不作为常驻服务计入运行实例。后端依赖 PostgreSQL 和 Redis 健康;前端依赖后端健康。 + +## 5. 镜像设计 + +### 5.1 后端镜像 + +后端使用多阶段构建: + +1. Go 构建阶段下载模块并编译 `cmd/server` +2. Python 运行阶段安装 `pkg/documentparser/python/requirements.txt` +3. 最终镜像只保留 Go 可执行文件、Python 运行时、解析脚本和必要系统证书 +4. 容器以非 root 用户运行,通过服务器预创建并授权的目录写入上传文件和日志 + +运行时固定使用以下路径: + +- `/app/solvify-agent`:后端可执行文件 +- `/app/configs/config.yaml`:生产配置 +- `/app/pkg/documentparser/python/parse_document.py`:解析脚本 +- `/app/data/uploads`:上传文件 +- `/app/logs`:日志 + +### 5.2 前端镜像 + +前端同样使用多阶段构建: + +1. Node 构建阶段执行 `npm ci` 和 `npm run build` +2. Nginx 运行阶段只复制 `dist` 和生产 Nginx 配置 +3. Nginx 对 Vue Router 使用 `try_files` 回退到 `index.html` +4. `/api/` 和 `/health` 转发至 `backend:8080`,保留 SSE 所需的流式代理设置 + +### 5.3 镜像命名与版本 + +镜像名称基于转为小写的 `${GITHUB_REPOSITORY}`: + +- `ghcr.io/-backend:` +- `ghcr.io/-frontend:` + +每次 `main` 发布同时更新 `latest` 标签。生产 Compose 始终使用不可变的提交 SHA 标签,`latest` 只用于人工查看和临时测试。 + +## 6. CI/CD 工作流 + +现有 `.github/workflows/ci.yml` 将由 `.github/workflows/ci-cd.yml` 取代,避免同一提交重复执行 Go 测试。 + +### 6.1 触发条件 + +- `pull_request` 指向 `main`:执行 CI,不发布和部署 +- `push` 到 `main`:执行 CI、构建发布镜像并部署 +- `workflow_dispatch`:允许人工触发所选分支或标签对应的流程 + +### 6.2 CI 作业 + +Go 作业执行: + +- 恢复 Go 模块缓存 +- `go test ./...` +- `go vet ./...` + +Vue 作业执行: + +- 使用 `design/vue/package-lock.json` 恢复 npm 缓存 +- `npm ci` +- `npm run build` + +Compose 与脚本作业执行: + +- 使用示例配置渲染 `docker compose config` +- 使用 `bash -n` 检查 Shell 脚本语法 +- 构建前后端 Docker 镜像,确保 Dockerfile 在 Pull Request 阶段即可验证 + +### 6.3 镜像发布作业 + +镜像发布仅在 `main` 的 `push` 或人工触发时执行,并依赖全部 CI 作业成功: + +- 使用工作流 `GITHUB_TOKEN` 登录 GHCR +- 权限限制为 `contents: read` 和 `packages: write` +- 构建 `linux/amd64` 前后端镜像 +- 推送提交 SHA 和 `latest` 两组标签 +- 构建结果通过 job outputs 传递给部署作业 + +### 6.4 部署作业 + +部署作业绑定 GitHub `production` Environment,并使用固定 concurrency group,保证同一时间只有一个生产部署。部署步骤为: + +1. 配置 SSH 私钥和已验证的 `known_hosts` +2. 将 Compose、数据库脚本和部署脚本同步到 `/opt/solvify-agent/releases/` +3. 通过标准输入让服务器临时登录 GHCR +4. 调用服务器上的部署脚本并传入两个 SHA 镜像地址 +5. 部署完成后退出 GHCR 登录 +6. 从 GitHub Runner 请求 `http:///health` 做外部检查 + +## 7. 生产配置与密钥 + +### 7.1 GitHub production Environment Secrets + +| Secret | 用途 | +| --- | --- | +| `DEPLOY_HOST` | 生产服务器地址 | +| `DEPLOY_PORT` | SSH 端口 | +| `DEPLOY_USER` | 部署用户 | +| `DEPLOY_SSH_KEY` | 部署专用 SSH 私钥 | +| `DEPLOY_KNOWN_HOSTS` | 预先核验的服务器主机公钥记录 | +| `GHCR_USERNAME` | GHCR 拉取账号 | +| `GHCR_PULL_TOKEN` | 仅授予 `read:packages` 的 GHCR Token | + +### 7.2 服务器本地配置 + +`server-bootstrap.sh` 在服务器创建以下目录: + +```text +/opt/solvify-agent/ +├── current +├── releases/ +└── shared/ + ├── .env + ├── config.yaml + ├── data/ + └── logs/ +``` + +`shared/.env` 保存数据库、Redis、LLM、Embedding 和第三方集成环境变量;`shared/config.yaml` 保存 JWT、邮件和 Agent 等当前项目只能从 YAML 读取的配置。两个文件权限为 `0600`,部署流程不会覆盖它们。 + +仓库只提供无真实凭据的示例文件。镜像、Compose、Actions 日志和 Git 历史中不写入生产密码、Token、密钥或完整连接串。 + +## 8. 数据与数据库更新 + +- PostgreSQL、Redis 使用 Docker named volume 持久化 +- 后端上传文件和日志绑定到 `/opt/solvify-agent/shared/data` 与 `shared/logs` +- PostgreSQL 首次创建数据卷时执行 `init_knowledge_schema.sql` +- 每次部署在更新应用前运行 `migrate` 一次性服务,再次执行同一幂等脚本 +- `seed_interface_data.sql` 只包含接口测试数据,不进入生产初始化流程 +- 后续数据库变更必须保持向前兼容,并显式使用 `ALTER ... IF NOT EXISTS` 等幂等语句 + +## 9. 部署与回滚 + +服务器部署脚本执行以下流程: + +1. 检查 Docker Engine、Compose v2、生产配置和镜像变量 +2. 使用 `flock` 获取服务器部署锁 +3. 将当前 `.release.env` 备份为 `.release.env.previous` +4. 写入本次提交对应的新 `.release.env` +5. 拉取前后端 SHA 镜像 +6. 启动 PostgreSQL 和 Redis并等待健康 +7. 运行 `migrate` 服务 +8. 更新后端和前端 +9. 等待后端容器健康,并通过前端代理请求 `/health` +10. 成功后更新 `current` 软链接并清理无引用的本地镜像 + +任一步骤失败后: + +- 有上一版本时恢复 `.release.env.previous`,重新启动上一版本前后端并验证健康 +- 首次部署没有上一版本时停止异常应用容器,但保留 PostgreSQL、Redis 和全部数据卷 +- 数据库只执行向前兼容变更,不自动执行结构降级 +- 脚本以非零状态退出,使 GitHub Actions 明确标记部署失败 + +## 10. 健康检查与日志 + +- PostgreSQL 使用 `pg_isready` +- Redis 使用带密码的 `redis-cli ping` +- 后端请求容器内 `http://127.0.0.1:8080/health` +- 前端请求 Nginx 的 `/health`,同时验证反向代理和后端 +- 部署超时或失败时输出 `docker compose ps` 和后端、前端的有限尾部日志 +- 不输出 `.env`、`config.yaml` 或 Docker Registry 登录信息 + +## 11. 交付文件 + +```text +.github/workflows/ci-cd.yml +Dockerfile +.dockerignore +design/vue/Dockerfile +design/vue/.dockerignore +design/vue/nginx.conf +deploy/compose.prod.yaml +deploy/deploy.sh +deploy/server-bootstrap.sh +deploy/.env.production.example +deploy/config.production.yaml.example +deploy/README.md +tasks/todo.md +``` + +现有 `.github/workflows/ci.yml` 和无效的 `deploy/compose.yaml.example` 将被对应生产文件取代。`tasks/todo.md` 是本地实施跟踪文件,继续遵循当前 `.gitignore`,不纳入 Git。 + +## 12. 验证与成功标准 + +实现完成前必须取得以下证据: + +- `go test ./...` 成功 +- `go vet ./...` 成功 +- `npm ci` 和 `npm run build` 成功 +- 前后端 Docker 镜像构建成功 +- 生产 Compose 使用示例变量执行 `docker compose config` 成功 +- `bash -n deploy/deploy.sh deploy/server-bootstrap.sh` 成功 +- Docker 可用时,本地启动完整 Compose 并通过 `/health` 冒烟测试 +- 最终 Git diff 不包含用户当前正在修改的文档编辑功能文件 + +完成标准是:Pull Request 只运行 CI;`main` 更新在 CI 成功后发布两个 SHA 镜像,并能在一台已完成初始化的 Linux 服务器上自动部署、通过健康检查,或在应用更新失败时恢复上一版本。 + +## 13. 前置假设 + +- 生产服务器为 `linux/amd64` +- 服务器已安装 Docker Engine、Docker Compose v2、OpenSSH 和 `flock` +- 部署用户可执行 Docker 命令并可写 `/opt/solvify-agent` +- 防火墙允许 SSH 端口和 TCP `80` +- GHCR Token 对两个私有镜像拥有读取权限 +- 当前阶段直接通过服务器地址访问,不配置 HTTPS From cf5e2eb3ac00d8bceada349c93aa49406621dd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E9=98=B3=20=E9=BE=9A?= <2302635277@qq.com> Date: Wed, 29 Jul 2026 09:56:03 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20GitHub=20Actio?= =?UTF-8?q?ns=E3=80=81GHCR=20=E5=92=8C=20Docker=20Compose=20=E7=9A=84?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=20CI/CD=20=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=B5=81=E7=A8=8B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 11 + .gitattributes | 6 + .github/workflows/ci-cd.yml | 269 +++++++++++++++++++ .github/workflows/ci.yml | 23 -- Dockerfile | 60 +++++ deploy/.env.production.example | 82 ++++++ deploy/CI-CD-DESIGN.md | 25 +- deploy/README.md | 142 ++++++++++ deploy/compose.prod.yaml | 123 +++++++++ deploy/compose.yaml.example | 82 ------ deploy/config.production.yaml.example | 127 +++++++++ deploy/deploy.sh | 208 ++++++++++++++ deploy/server-bootstrap.sh | 86 ++++++ design/vue/.dockerignore | 8 + design/vue/Dockerfile | 25 ++ design/vue/nginx.conf | 44 +++ internal/integration/dingtalk/client_test.go | 2 +- 17 files changed, 1205 insertions(+), 118 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/workflows/ci-cd.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 deploy/.env.production.example create mode 100644 deploy/README.md create mode 100644 deploy/compose.prod.yaml delete mode 100644 deploy/compose.yaml.example create mode 100644 deploy/config.production.yaml.example create mode 100644 deploy/deploy.sh create mode 100644 deploy/server-bootstrap.sh create mode 100644 design/vue/.dockerignore create mode 100644 design/vue/Dockerfile create mode 100644 design/vue/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ba0ad77 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +* +!Dockerfile +!go.mod +!go.sum +!cmd/ +!cmd/** +!internal/ +!internal/** +!pkg/ +!pkg/** + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfed0a8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.conf text eol=lf +Dockerfile text eol=lf + diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..f27c9cf --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,269 @@ +name: CI/CD + +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + go-check: + name: Go 测试与静态检查 + runs-on: ubuntu-latest + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 配置 Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: 运行测试 + run: go test -race -coverprofile=coverage.out ./... + + - name: 运行静态检查 + run: go vet ./... + + - name: 编译后端 + run: go build -buildvcs=false -o /tmp/solvify-agent ./cmd/server + + frontend-check: + name: Vue 生产构建 + runs-on: ubuntu-latest + defaults: + run: + working-directory: design/vue + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 配置 Node.js + uses: actions/setup-node@v7 + with: + node-version: "22" + cache: npm + cache-dependency-path: design/vue/package-lock.json + + - name: 安装依赖 + run: npm ci + + - name: 构建前端 + run: npm run build + + deployment-check: + name: 部署配置检查 + runs-on: ubuntu-latest + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 检查 Shell 语法 + run: bash -n deploy/deploy.sh deploy/server-bootstrap.sh + + - name: 准备 Compose 检查配置 + env: + VALIDATION_ROOT: ${{ runner.temp }}/solvify-agent + run: | + mkdir -p "$VALIDATION_ROOT/shared" + cp deploy/.env.production.example "$VALIDATION_ROOT/shared/.env" + cp deploy/config.production.yaml.example "$VALIDATION_ROOT/shared/config.yaml" + sed -i 's/CHANGE_ME_POSTGRES_PASSWORD/ci-postgres-password/' "$VALIDATION_ROOT/shared/.env" + sed -i 's/CHANGE_ME_REDIS_PASSWORD/ci-redis-password/' "$VALIDATION_ROOT/shared/.env" + sed -i 's/CHANGE_ME_JWT_SECRET/ci-jwt-secret/' "$VALIDATION_ROOT/shared/config.yaml" + { + echo "BACKEND_IMAGE=ghcr.io/example/solvify-agent-backend:ci" + echo "FRONTEND_IMAGE=ghcr.io/example/solvify-agent-frontend:ci" + echo "RELEASE_SHA=0000000000000000000000000000000000000000" + echo "SOLVIFY_ROOT=$VALIDATION_ROOT" + } > "$VALIDATION_ROOT/shared/.release.env" + + - name: 检查 Compose 配置 + env: + VALIDATION_ROOT: ${{ runner.temp }}/solvify-agent + run: | + docker compose \ + --env-file "$VALIDATION_ROOT/shared/.env" \ + --env-file "$VALIDATION_ROOT/shared/.release.env" \ + --file deploy/compose.prod.yaml \ + config --quiet + + images: + name: 构建并发布镜像 + runs-on: ubuntu-latest + needs: + - go-check + - frontend-check + - deployment-check + permissions: + contents: read + packages: write + outputs: + backend-image: ${{ steps.image-names.outputs.backend }} + frontend-image: ${{ steps.image-names.outputs.frontend }} + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 生成镜像名称 + id: image-names + shell: bash + run: | + repository="${GITHUB_REPOSITORY,,}" + echo "backend=ghcr.io/${repository}-backend" >> "$GITHUB_OUTPUT" + echo "frontend=ghcr.io/${repository}-frontend" >> "$GITHUB_OUTPUT" + + - name: 登录 GHCR + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 配置 Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: 构建后端镜像 + uses: docker/build-push-action@v7 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + push: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} + tags: | + ${{ steps.image-names.outputs.backend }}:${{ github.sha }} + ${{ steps.image-names.outputs.backend }}:latest + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=backend + cache-to: type=gha,scope=backend,mode=max + + - name: 构建前端镜像 + uses: docker/build-push-action@v7 + with: + context: design/vue + file: design/vue/Dockerfile + platforms: linux/amd64 + push: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} + tags: | + ${{ steps.image-names.outputs.frontend }}:${{ github.sha }} + ${{ steps.image-names.outputs.frontend }}:latest + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=frontend + cache-to: type=gha,scope=frontend,mode=max + + deploy: + name: 部署生产环境 + runs-on: ubuntu-latest + needs: + - images + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + environment: + name: production + url: ${{ vars.PRODUCTION_URL }} + concurrency: + group: solvify-agent-production + cancel-in-progress: false + steps: + - name: 检出代码 + uses: actions/checkout@v6 + + - name: 配置 SSH + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + run: | + test -n "$DEPLOY_HOST" + test -n "$DEPLOY_PORT" + test -n "$DEPLOY_USER" + test -n "$DEPLOY_SSH_KEY" + test -n "$DEPLOY_KNOWN_HOSTS" + + install -d -m 0700 "$HOME/.ssh" + printf '%s\n' "$DEPLOY_SSH_KEY" > "$HOME/.ssh/solvify_deploy" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" + chmod 0600 "$HOME/.ssh/solvify_deploy" "$HOME/.ssh/known_hosts" + + { + echo "Host solvify-production" + echo " HostName $DEPLOY_HOST" + echo " Port $DEPLOY_PORT" + echo " User $DEPLOY_USER" + echo " IdentityFile $HOME/.ssh/solvify_deploy" + echo " IdentitiesOnly yes" + echo " BatchMode yes" + echo " StrictHostKeyChecking yes" + echo " UserKnownHostsFile $HOME/.ssh/known_hosts" + } > "$HOME/.ssh/config" + chmod 0600 "$HOME/.ssh/config" + + ssh solvify-production "printf 'SSH 连接成功\n'" + + - name: 组装部署包 + env: + PACKAGE_DIR: ${{ runner.temp }}/solvify-deploy-package + run: | + mkdir -p "$PACKAGE_DIR/postgres/init" + cp deploy/compose.prod.yaml "$PACKAGE_DIR/" + cp deploy/deploy.sh "$PACKAGE_DIR/" + cp deploy/server-bootstrap.sh "$PACKAGE_DIR/" + cp deploy/.env.production.example "$PACKAGE_DIR/" + cp deploy/config.production.yaml.example "$PACKAGE_DIR/" + cp scripts/init_knowledge_schema.sql "$PACKAGE_DIR/postgres/init/001-init-schema.sql" + tar -C "$PACKAGE_DIR" -czf "${{ runner.temp }}/solvify-deploy-package.tar.gz" . + + - name: 上传部署包 + env: + RELEASE_DIR: /opt/solvify-agent/releases/${{ github.sha }} + run: | + ssh solvify-production "mkdir -p '$RELEASE_DIR'" + scp "${{ runner.temp }}/solvify-deploy-package.tar.gz" \ + "solvify-production:$RELEASE_DIR/package.tar.gz" + ssh solvify-production \ + "tar -xzf '$RELEASE_DIR/package.tar.gz' -C '$RELEASE_DIR' \ + && rm -f '$RELEASE_DIR/package.tar.gz' \ + && chmod 0755 '$RELEASE_DIR/deploy.sh' '$RELEASE_DIR/server-bootstrap.sh'" + + - name: 执行部署 + env: + BACKEND_IMAGE: ${{ needs.images.outputs.backend-image }}:${{ github.sha }} + FRONTEND_IMAGE: ${{ needs.images.outputs.frontend-image }}:${{ github.sha }} + RELEASE_SHA: ${{ github.sha }} + run: | + ssh solvify-production \ + "SOLVIFY_ROOT=/opt/solvify-agent \ + bash '/opt/solvify-agent/releases/$RELEASE_SHA/deploy.sh' \ + '$BACKEND_IMAGE' '$FRONTEND_IMAGE' '$RELEASE_SHA'" + + - name: 外部健康检查 + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + PRODUCTION_URL: ${{ vars.PRODUCTION_URL }} + run: | + health_base_url="${PRODUCTION_URL:-http://${DEPLOY_HOST}:18888}" + for attempt in $(seq 1 20); do + if curl --fail --silent --show-error --max-time 10 \ + "${health_base_url%/}/health" >/dev/null; then + echo "生产环境健康检查通过" + exit 0 + fi + echo "等待生产环境健康检查,第 ${attempt} 次" + sleep 5 + done + echo "生产环境外部健康检查失败" >&2 + exit 1 + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 09ca56d..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: CI - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: "1.26.x" - - - name: Test - run: go test ./... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2477110 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.7 + +ARG GO_VERSION=1.26.2 + +FROM golang:${GO_VERSION}-bookworm AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY cmd ./cmd +COPY internal ./internal +COPY pkg ./pkg + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build \ + -buildvcs=false \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/solvify-agent \ + ./cmd/server + +FROM python:3.13-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + CONFIG_PATH=/app/configs/config.yaml \ + DOCUMENT_PARSER_PYTHON_PATH=python \ + DOCUMENT_PARSER_SCRIPT_PATH=/app/pkg/documentparser/python/parse_document.py + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY pkg/documentparser/python/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --disable-pip-version-check -r /tmp/requirements.txt \ + && rm -f /tmp/requirements.txt + +RUN groupadd --gid 10001 app \ + && useradd --uid 10001 --gid app --create-home --shell /usr/sbin/nologin app \ + && mkdir -p /app/configs /app/data/uploads /app/logs /app/pkg/documentparser/python \ + && chown -R app:app /app + +COPY --from=builder --chown=app:app /out/solvify-agent /app/solvify-agent +COPY --chown=app:app pkg/documentparser/python/parse_document.py /app/pkg/documentparser/python/parse_document.py + +USER app + +EXPOSE 8080 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=3)"] + +ENTRYPOINT ["/app/solvify-agent"] + diff --git a/deploy/.env.production.example b/deploy/.env.production.example new file mode 100644 index 0000000..d796581 --- /dev/null +++ b/deploy/.env.production.example @@ -0,0 +1,82 @@ +# Compose 配置 +SOLVIFY_ROOT=/opt/solvify-agent +APP_PORT=18888 + +# 应用基础配置 +APP_ENV=production +APP_MODE=release +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 +SHUTDOWN_TIMEOUT_SECONDS=20 + +# 日志配置 +LOG_LEVEL=info +LOG_FILENAME=/app/logs/solvify-agent.log +LOG_MAX_SIZE=100 +LOG_MAX_BACKUPS=7 +LOG_MAX_AGE=30 +LOG_COMPRESS=true + +# LLM 配置 +LLM_PROVIDER=mock +LLM_MODEL=mock-knowledge-assistant +LLM_API_KEY= +LLM_BASE_URL= +LLM_TEMPERATURE=0.7 +LLM_MAX_TOKENS=2000 +LLM_TIMEOUT=30 + +# Embedding 配置 +EMBEDDING_PROVIDER=openai +EMBEDDING_MODEL=text-embedding-v4 +EMBEDDING_API_KEY= +EMBEDDING_BASE_URL= +EMBEDDING_DIMENSION=1024 +EMBEDDING_BATCH_SIZE=10 + +# RAG 配置 +RAG_ENABLED=true +RERANKER_ENABLED=false +RERANKER_ENDPOINT= +RERANKER_MODEL= +RERANKER_API_KEY= +RERANKER_TOP_N=3 +RERANKER_TIMEOUT=10 +RERANKER_SCORE_THRESHOLD=0.5 +EXPANDER_ENABLED=false +EXPANDER_WINDOW_SIZE=1 +EXPANDER_MAX_CHUNK_TOKENS=1000 +EXPANDER_DEDUP_THRESHOLD=0.8 + +# 工具配置 +TOOLS_ENABLED=true + +# 钉钉配置 +DINGTALK_APP_KEY= +DINGTALK_APP_SECRET= +DINGTALK_OAUTH_REDIRECT_URI=http://127.0.0.1:18888/dingtalk/bind + +# 文档解析配置 +DOCUMENT_PARSER_PYTHON_PATH=python +DOCUMENT_PARSER_SCRIPT_PATH=/app/pkg/documentparser/python/parse_document.py +DOCUMENT_PARSER_TIMEOUT_SECONDS=30 + +# PostgreSQL 配置 +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_USERNAME=solvify +POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD +POSTGRES_DATABASE=solvify_agent +POSTGRES_TIMEZONE=Asia/Shanghai +POSTGRES_MAX_IDLE_CONNS=5 +POSTGRES_MAX_OPEN_CONNS=20 +POSTGRES_CONN_MAX_LIFETIME_MINUTES=60 +POSTGRES_ENABLE_PGVECTOR=true + +# Redis 配置 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD +REDIS_DB=0 +REDIS_POOL_SIZE=10 + diff --git a/deploy/CI-CD-DESIGN.md b/deploy/CI-CD-DESIGN.md index 500cd12..ae1be30 100644 --- a/deploy/CI-CD-DESIGN.md +++ b/deploy/CI-CD-DESIGN.md @@ -24,12 +24,12 @@ ## 4. 最终架构 -生产服务器只对外开放 TCP `80`。Compose 内部使用独立网络连接以下服务: +生产服务器只对外开放应用端口 TCP `18888`。Compose 内部使用独立网络连接以下服务: | 服务 | 镜像 | 职责 | 对外端口 | | --- | --- | --- | --- | -| `frontend` | GHCR 前端镜像 | Nginx 托管 Vue 静态资源,并反向代理 `/api/` 和 `/health` | `80` | -| `backend` | GHCR 后端镜像 | 运行 Go API,并提供 Python 文档解析能力 | 无 | +| `frontend` | GHCR 公开前端镜像 | Nginx 托管 Vue 静态资源,并反向代理 `/api/` 和 `/health` | 宿主机 `18888` → 容器 `80` | +| `backend` | GHCR 公开后端镜像 | 运行 Go API,并提供 Python 文档解析能力 | 无 | | `postgres` | pgvector PostgreSQL 官方镜像 | 持久化业务数据和向量索引 | 无 | | `redis` | Redis 官方镜像 | 缓存、验证码和 Token 黑名单 | 无 | | `migrate` | pgvector PostgreSQL 官方镜像 | 部署时一次性执行幂等数据库脚本 | 无 | @@ -73,6 +73,8 @@ 每次 `main` 发布同时更新 `latest` 标签。生产 Compose 始终使用不可变的提交 SHA 标签,`latest` 只用于人工查看和临时测试。 +GitHub Actions 使用仓库自带的 `GITHUB_TOKEN` 推送镜像,不需要配置 Docker Hub 用户名或 Token。服务器匿名拉取公开镜像;两个包首次发布后需要在 GitHub Packages 中分别将可见性手动设置为 `Public`。 + ## 6. CI/CD 工作流 现有 `.github/workflows/ci.yml` 将由 `.github/workflows/ci-cd.yml` 取代,避免同一提交重复执行 Go 测试。 @@ -119,10 +121,9 @@ Compose 与脚本作业执行: 1. 配置 SSH 私钥和已验证的 `known_hosts` 2. 将 Compose、数据库脚本和部署脚本同步到 `/opt/solvify-agent/releases/` -3. 通过标准输入让服务器临时登录 GHCR -4. 调用服务器上的部署脚本并传入两个 SHA 镜像地址 -5. 部署完成后退出 GHCR 登录 -6. 从 GitHub Runner 请求 `http:///health` 做外部检查 +3. 调用服务器上的部署脚本并传入两个 SHA 镜像地址 +4. 服务器匿名拉取两个公开 GHCR 镜像并更新 Compose 服务 +5. 从 GitHub Runner 请求 `http://:18888/health` 做外部检查 ## 7. 生产配置与密钥 @@ -135,8 +136,8 @@ Compose 与脚本作业执行: | `DEPLOY_USER` | 部署用户 | | `DEPLOY_SSH_KEY` | 部署专用 SSH 私钥 | | `DEPLOY_KNOWN_HOSTS` | 预先核验的服务器主机公钥记录 | -| `GHCR_USERNAME` | GHCR 拉取账号 | -| `GHCR_PULL_TOKEN` | 仅授予 `read:packages` 的 GHCR Token | + +`DEPLOY_USER` 是 Linux 生产服务器上的部署账号,不是 GitHub 用户。公开 GHCR 方案不需要额外的镜像仓库账号或 Token。 ### 7.2 服务器本地配置 @@ -153,7 +154,7 @@ Compose 与脚本作业执行: └── logs/ ``` -`shared/.env` 保存数据库、Redis、LLM、Embedding 和第三方集成环境变量;`shared/config.yaml` 保存 JWT、邮件和 Agent 等当前项目只能从 YAML 读取的配置。两个文件权限为 `0600`,部署流程不会覆盖它们。 +`shared/.env` 保存数据库、Redis、LLM、Embedding 和第三方集成环境变量;`shared/config.yaml` 保存 JWT、邮件和 Agent 等当前项目只能从 YAML 读取的配置。`.env` 权限为 `0600`;`config.yaml` 权限为 `0640`,并通过容器运行组只读访问。部署流程不会覆盖它们。 仓库只提供无真实凭据的示例文件。镜像、Compose、Actions 日志和 Git 历史中不写入生产密码、Token、密钥或完整连接串。 @@ -237,6 +238,6 @@ tasks/todo.md - 生产服务器为 `linux/amd64` - 服务器已安装 Docker Engine、Docker Compose v2、OpenSSH 和 `flock` - 部署用户可执行 Docker 命令并可写 `/opt/solvify-agent` -- 防火墙允许 SSH 端口和 TCP `80` -- GHCR Token 对两个私有镜像拥有读取权限 +- 防火墙允许 SSH 端口和 TCP `18888` +- 两个 GHCR 镜像包在首次发布后已手动设置为公开 - 当前阶段直接通过服务器地址访问,不配置 HTTPS diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..4c3eb5d --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,142 @@ +# Docker Compose 生产部署 + +本目录用于将 Solvify-Agent 部署到一台 Linux 服务器。生产环境包含 Vue/Nginx 前端、Go 后端、PostgreSQL/pgvector、Redis 和一次性数据库迁移任务。 + +## 默认约定 + +- GitHub 只使用 `main` 分支 +- Pull Request 执行 CI,不部署 +- `main` Push 在 CI 成功后发布两个公开 GHCR 镜像并部署 +- 宿主机 `18888` 映射前端容器 `80` +- 后端、PostgreSQL 和 Redis 不发布宿主机端口 +- 默认部署目录为 `/opt/solvify-agent` +- 默认目标平台为 `linux/amd64` + +## 1. 准备服务器 + +服务器需要安装: + +- Docker Engine +- Docker Compose v2 +- OpenSSH Server +- `flock` + +检查命令: + +```bash +docker --version +docker compose version +docker ps +``` + +第一次部署前,将本目录复制到服务器,然后以 root 权限初始化目录。`DEPLOY_USER` 是服务器上的 Linux 用户: + +```bash +sudo DEPLOY_USER=deploy bash server-bootstrap.sh +``` + +如果使用云服务器默认用户: + +```bash +sudo DEPLOY_USER=ubuntu bash server-bootstrap.sh +``` + +重新登录服务器后编辑: + +```text +/opt/solvify-agent/shared/.env +/opt/solvify-agent/shared/config.yaml +``` + +必须替换所有 `CHANGE_ME`,并按需填写 LLM、Embedding、邮件和钉钉配置。 + +## 2. 配置 SSH + +为部署创建专用 SSH 密钥,将公钥追加到服务器部署用户的: + +```text +~/.ssh/authorized_keys +``` + +在 GitHub 仓库的 `Settings → Environments → production` 中创建以下 Secrets: + +| 名称 | 内容 | +| --- | --- | +| `DEPLOY_HOST` | 服务器 IP 或主机名 | +| `DEPLOY_PORT` | SSH 端口,通常为 `22` | +| `DEPLOY_USER` | 服务器 Linux 部署用户 | +| `DEPLOY_SSH_KEY` | 部署专用 SSH 私钥 | +| `DEPLOY_KNOWN_HOSTS` | 已人工核验的服务器主机公钥记录 | + +生成 `known_hosts` 候选记录: + +```bash +ssh-keyscan -p 22 服务器地址 +``` + +保存前需要通过云控制台或服务器管理渠道核验主机密钥指纹,不能只信任当前网络返回结果。 + +## 3. 发布 GHCR 镜像 + +工作流使用仓库自带的 `GITHUB_TOKEN` 发布: + +```text +ghcr.io//-backend: +ghcr.io//-frontend: +``` + +第一次发布后,进入两个 Package 的设置,将可见性改为 `Public`。公开镜像允许服务器匿名拉取,因此服务器不保存 GHCR Token。 + +## 4. 首次部署 + +服务器初始化和生产配置填写完成后,推送或合并代码到 `main`。工作流会: + +1. 运行 Go 测试和静态检查 +2. 构建 Vue 前端 +3. 验证 Shell、Compose 和 Dockerfile +4. 发布 SHA 与 `latest` 标签 +5. 上传部署包并调用 `deploy.sh` +6. 检查 `http://:18888/health` + +如果第一次发布因 GHCR Package 仍为私有而失败,将 Package 改为公开后,在 Actions 页面重新运行失败的工作流。 + +## 5. 回滚 + +部署脚本将当前镜像记录保存在: + +```text +/opt/solvify-agent/shared/.release.env +``` + +每次更新前备份为: + +```text +/opt/solvify-agent/shared/.release.env.previous +``` + +新版本健康检查失败时,脚本会恢复上一组提交 SHA 镜像。数据库脚本只允许向前兼容的幂等变更,不执行数据库结构降级。 + +## 6. 常用运维命令 + +```bash +cd /opt/solvify-agent/current + +docker compose \ + --project-name solvify-agent \ + --env-file /opt/solvify-agent/shared/.env \ + --env-file /opt/solvify-agent/shared/.release.env \ + --file compose.prod.yaml \ + ps +``` + +查看应用日志: + +```bash +docker compose \ + --project-name solvify-agent \ + --env-file /opt/solvify-agent/shared/.env \ + --env-file /opt/solvify-agent/shared/.release.env \ + --file compose.prod.yaml \ + logs --tail=100 backend frontend +``` + diff --git a/deploy/compose.prod.yaml b/deploy/compose.prod.yaml new file mode 100644 index 0000000..bd8720a --- /dev/null +++ b/deploy/compose.prod.yaml @@ -0,0 +1,123 @@ +name: solvify-agent + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + postgres: + image: pgvector/pgvector:pg16 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USERNAME:?POSTGRES_USERNAME 未配置} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未配置} + POSTGRES_DB: ${POSTGRES_DATABASE:?POSTGRES_DATABASE 未配置} + TZ: ${POSTGRES_TIMEZONE:-Asia/Shanghai} + expose: + - "5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/init/001-init-schema.sql:/docker-entrypoint-initdb.d/001-init-schema.sql:ro + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + logging: *default-logging + networks: + - solvify + + redis: + image: redis:7.4-alpine + restart: unless-stopped + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD 未配置} + command: + - /bin/sh + - -c + - exec redis-server --appendonly yes --requirepass "$${REDIS_PASSWORD}" + expose: + - "6379" + volumes: + - redis_data:/data + healthcheck: + test: + - CMD-SHELL + - redis-cli -a "$${REDIS_PASSWORD}" ping 2>/dev/null | grep -q PONG + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + logging: *default-logging + networks: + - solvify + + migrate: + image: pgvector/pgvector:pg16 + profiles: + - migration + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未配置} + command: + - psql + - --set=ON_ERROR_STOP=1 + - --host=postgres + - --username=${POSTGRES_USERNAME:?POSTGRES_USERNAME 未配置} + - --dbname=${POSTGRES_DATABASE:?POSTGRES_DATABASE 未配置} + - --file=/migrations/001-init-schema.sql + volumes: + - ./postgres/init/001-init-schema.sql:/migrations/001-init-schema.sql:ro + depends_on: + postgres: + condition: service_healthy + logging: *default-logging + networks: + - solvify + + backend: + image: ${BACKEND_IMAGE:?BACKEND_IMAGE 未配置} + restart: unless-stopped + init: true + env_file: + - ${SOLVIFY_ROOT:-/opt/solvify-agent}/shared/.env + expose: + - "8080" + volumes: + - ${SOLVIFY_ROOT:-/opt/solvify-agent}/shared/config.yaml:/app/configs/config.yaml:ro + - ${SOLVIFY_ROOT:-/opt/solvify-agent}/shared/data:/app/data + - ${SOLVIFY_ROOT:-/opt/solvify-agent}/shared/logs:/app/logs + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + stop_grace_period: 30s + logging: *default-logging + networks: + - solvify + + frontend: + image: ${FRONTEND_IMAGE:?FRONTEND_IMAGE 未配置} + restart: unless-stopped + ports: + - "${APP_PORT:-18888}:80" + depends_on: + backend: + condition: service_healthy + logging: *default-logging + networks: + - solvify + +networks: + solvify: + driver: bridge + +volumes: + postgres_data: + redis_data: + diff --git a/deploy/compose.yaml.example b/deploy/compose.yaml.example deleted file mode 100644 index 13a8542..0000000 --- a/deploy/compose.yaml.example +++ /dev/null @@ -1,82 +0,0 @@ -# Docker Compose 部署示例 -# 按实际前端目录、镜像仓库和密钥配置调整后再用于生产环境 - -services: - frontend: - image: solvify-frontend:latest - build: - context: ../frontend - dockerfile: Dockerfile - container_name: solvify-frontend - restart: unless-stopped - ports: - - "80:80" - depends_on: - backend: - condition: service_started - networks: - - solvify - - backend: - image: solvify-agent:latest - build: - context: .. - dockerfile: Dockerfile - container_name: solvify-agent - restart: unless-stopped - ports: - - "8080:8080" - environment: - APP_ENV: production - APP_MODE: release - SERVER_HOST: "0.0.0.0" - SERVER_PORT: "8080" - LOG_LEVEL: info - LOG_FILENAME: logs/solvify-agent.log - LLM_PROVIDER: openai - LLM_MODEL: gpt-4.1-mini - LLM_API_KEY: ${LLM_API_KEY} - RAG_ENABLED: "true" - TOOLS_ENABLED: "true" - POSTGRES_HOST: postgres - POSTGRES_PORT: "5432" - POSTGRES_DB: solvify - POSTGRES_USER: solvify - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_SSL_MODE: disable - volumes: - - backend_logs:/app/logs - depends_on: - postgres: - condition: service_healthy - networks: - - solvify - - postgres: - image: pgvector/pgvector:pg16 - container_name: solvify-postgres - restart: unless-stopped - environment: - POSTGRES_DB: solvify - POSTGRES_USER: solvify - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./postgres/init:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD-SHELL", "pg_isready -U solvify -d solvify"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - solvify - -networks: - solvify: - driver: bridge - -volumes: - postgres_data: - backend_logs: diff --git a/deploy/config.production.yaml.example b/deploy/config.production.yaml.example new file mode 100644 index 0000000..997a252 --- /dev/null +++ b/deploy/config.production.yaml.example @@ -0,0 +1,127 @@ +app: + name: solvify-agent + version: 0.1.0 + env: production + mode: release + +server: + host: "0.0.0.0" + port: 8080 + shutdown_timeout_seconds: 20 + +jwt: + secret: "CHANGE_ME_JWT_SECRET" + expire_hours: 24 + +log: + level: info + filename: /app/logs/solvify-agent.log + max_size: 100 + max_backups: 7 + max_age: 30 + compress: true + +cors: + enabled: true + allow_origins: + - "*" + allow_methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allow_headers: + - Origin + - Content-Type + - Authorization + expose_headers: [] + allow_credentials: false + max_age: 0 + +agent: + enable_demo: true + max_iterations: 10 + score_threshold: 0.7 + +llm: + provider: mock + api_format: "" + model: mock-knowledge-assistant + api_key: "" + base_url: "" + temperature: 0.7 + max_tokens: 2000 + timeout: 30 + +embedding: + provider: openai + model: text-embedding-v4 + api_key: "" + base_url: "" + dimension: 1024 + batch_size: 10 + +rag: + enabled: true + top_k: 3 + recall_k: 20 + score_threshold: 0.5 + vector_weight: 0.7 + keyword_weight: 0.3 + rrf_k: 60.0 + reranker: + enabled: false + endpoint: "" + model: "" + api_key: "" + top_n: 3 + timeout: 10 + score_threshold: 0.5 + expander: + enabled: false + window_size: 1 + max_chunk_tokens: 1000 + dedup_threshold: 0.8 + +tools: + enabled: true + web_search: + api_key: "" + base_url: "" + +dingtalk: + app_key: "" + app_secret: "" + oauth_redirect_uri: "http://127.0.0.1:18888/dingtalk/bind" + +document_parser: + python_path: python + script_path: /app/pkg/documentparser/python/parse_document.py + timeout_seconds: 30 + +database: + postgres: + host: postgres + port: 5432 + username: solvify + password: "" + database: solvify_agent + timezone: Asia/Shanghai + max_idle_conns: 5 + max_open_conns: 20 + conn_max_lifetime_minutes: 60 + enable_pgvector: true + redis: + host: redis + port: 6379 + password: "" + db: 0 + pool_size: 10 + +email: + host: "" + port: 465 + username: "" + password: "" + diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..7f48700 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SOLVIFY_ROOT="${SOLVIFY_ROOT:-/opt/solvify-agent}" +COMPOSE_FILE="$SCRIPT_DIR/compose.prod.yaml" +ENV_FILE="$SOLVIFY_ROOT/shared/.env" +CONFIG_FILE="$SOLVIFY_ROOT/shared/config.yaml" +RELEASE_ENV="$SOLVIFY_ROOT/shared/.release.env" +PREVIOUS_RELEASE_ENV="$SOLVIFY_ROOT/shared/.release.env.previous" +LOCK_FILE="$SOLVIFY_ROOT/deploy.lock" +COMPOSE_PROJECT_NAME="solvify-agent" +HEALTH_TIMEOUT_SECONDS="${HEALTH_TIMEOUT_SECONDS:-180}" + +# 输出部署信息 +log() { + printf '[部署] %s\n' "$*" +} + +# 输出错误并终止 +fail() { + printf '[部署错误] %s\n' "$*" >&2 + exit 1 +} + +# 显示脚本使用方式 +usage() { + printf '用法:%s <后端镜像> <前端镜像> <提交 SHA>\n' "$0" +} + +# 检查服务器所需命令 +require_command() { + local command_name="$1" + command -v "$command_name" >/dev/null 2>&1 || fail "缺少命令:${command_name}" +} + +# 执行固定项目和配置文件的 Compose 命令 +compose_command() { + docker compose \ + --project-name "$COMPOSE_PROJECT_NAME" \ + --env-file "$ENV_FILE" \ + --env-file "$RELEASE_ENV" \ + --file "$COMPOSE_FILE" \ + "$@" +} + +# 等待指定服务进入健康状态 +wait_for_health() { + local service_name="$1" + local deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS)) + local container_id + local health_status + + while (( SECONDS < deadline )); do + container_id="$(compose_command ps -q "$service_name" 2>/dev/null || true)" + if [[ -n "$container_id" ]]; then + health_status="$(docker inspect \ + --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ + "$container_id" 2>/dev/null || true)" + case "$health_status" in + healthy) + log "${service_name} 健康检查通过" + return 0 + ;; + exited|dead|unhealthy) + log "${service_name} 状态异常:${health_status}" + return 1 + ;; + esac + fi + sleep 3 + done + + log "${service_name} 健康检查超时" + return 1 +} + +# 输出有限诊断信息且不读取配置文件 +print_diagnostics() { + compose_command ps || true + compose_command logs --tail=50 backend frontend || true +} + +# 恢复上一组不可变镜像 +rollback() { + if [[ ! -f "$PREVIOUS_RELEASE_ENV" ]]; then + log "没有可用的上一版本,停止异常应用容器并保留数据服务" + compose_command stop frontend backend || true + return 1 + fi + + log "开始恢复上一版本镜像" + cp "$PREVIOUS_RELEASE_ENV" "$RELEASE_ENV" + chmod 0600 "$RELEASE_ENV" + + compose_command pull backend frontend || true + compose_command up -d --no-deps backend || return 1 + wait_for_health backend || return 1 + compose_command up -d --no-deps frontend || return 1 + wait_for_health frontend || return 1 + + log "上一版本恢复完成" +} + +# 捕获部署错误并触发回滚 +handle_error() { + local exit_code="$?" + local line_number="${1:-未知}" + + trap - ERR + set +e + log "部署在第 ${line_number} 行失败" + print_diagnostics + rollback + local rollback_code="$?" + if [[ "$rollback_code" -ne 0 ]]; then + log "自动回滚未完成,请人工检查" + fi + exit "$exit_code" +} + +# 写入本次发布镜像信息 +write_release_env() { + local backend_image="$1" + local frontend_image="$2" + local release_sha="$3" + local temporary_file="${RELEASE_ENV}.tmp" + + umask 077 + { + printf 'BACKEND_IMAGE=%s\n' "$backend_image" + printf 'FRONTEND_IMAGE=%s\n' "$frontend_image" + printf 'RELEASE_SHA=%s\n' "$release_sha" + printf 'SOLVIFY_ROOT=%s\n' "$SOLVIFY_ROOT" + } >"$temporary_file" + mv "$temporary_file" "$RELEASE_ENV" +} + +# 执行生产部署 +main() { + [[ "$#" -eq 3 ]] || { + usage + exit 2 + } + + local backend_image="$1" + local frontend_image="$2" + local release_sha="$3" + local app_port + + [[ "$backend_image" == ghcr.io/*:* ]] || fail "后端镜像地址必须来自 ghcr.io" + [[ "$frontend_image" == ghcr.io/*:* ]] || fail "前端镜像地址必须来自 ghcr.io" + [[ "$release_sha" =~ ^[0-9a-f]{7,40}$ ]] || fail "提交 SHA 格式无效" + + require_command docker + require_command flock + docker compose version >/dev/null 2>&1 || fail "需要安装 Docker Compose v2" + + [[ -f "$COMPOSE_FILE" ]] || fail "Compose 文件不存在:${COMPOSE_FILE}" + [[ -f "$ENV_FILE" ]] || fail "生产环境变量不存在:${ENV_FILE}" + [[ -f "$CONFIG_FILE" ]] || fail "生产配置不存在:${CONFIG_FILE}" + if grep -q "CHANGE_ME" "$ENV_FILE" "$CONFIG_FILE"; then + fail "生产配置仍包含 CHANGE_ME,请先填写真实值" + fi + + exec 9>"$LOCK_FILE" + flock -n 9 || fail "已有部署正在执行" + + rm -f "$PREVIOUS_RELEASE_ENV" + if [[ -f "$RELEASE_ENV" ]]; then + cp "$RELEASE_ENV" "$PREVIOUS_RELEASE_ENV" + chmod 0600 "$PREVIOUS_RELEASE_ENV" + fi + write_release_env "$backend_image" "$frontend_image" "$release_sha" + + trap 'handle_error "$LINENO"' ERR + + log "拉取提交 ${release_sha} 对应的应用镜像" + compose_command pull backend frontend + + log "启动 PostgreSQL 和 Redis" + compose_command up -d postgres redis + wait_for_health postgres + wait_for_health redis + + log "执行数据库幂等初始化" + compose_command --profile migration run --rm migrate + + log "更新后端" + compose_command up -d --no-deps backend + wait_for_health backend + + log "更新前端" + compose_command up -d --no-deps frontend + wait_for_health frontend + + ln -sfn "$SCRIPT_DIR" "$SOLVIFY_ROOT/current" + docker image prune -f >/dev/null + + trap - ERR + app_port="$(sed -n 's/^APP_PORT=//p' "$ENV_FILE" | tail -n 1)" + app_port="${app_port:-18888}" + log "部署成功:${release_sha}" + log "访问地址:http://服务器地址:${app_port}/" +} + +main "$@" diff --git a/deploy/server-bootstrap.sh b/deploy/server-bootstrap.sh new file mode 100644 index 0000000..e097f5c --- /dev/null +++ b/deploy/server-bootstrap.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +SOLVIFY_ROOT="${SOLVIFY_ROOT:-/opt/solvify-agent}" +DEPLOY_USER="${DEPLOY_USER:-${SUDO_USER:-}}" + +# 输出普通信息 +log() { + printf '[初始化] %s\n' "$*" +} + +# 输出错误并终止 +fail() { + printf '[初始化错误] %s\n' "$*" >&2 + exit 1 +} + +# 检查服务器所需命令 +require_command() { + local command_name="$1" + command -v "$command_name" >/dev/null 2>&1 || fail "缺少命令:${command_name}" +} + +# 复制首次配置且不覆盖已有文件 +install_if_missing() { + local source_file="$1" + local target_file="$2" + local mode="$3" + + if [[ -e "$target_file" ]]; then + log "保留已有配置:${target_file}" + return + fi + + install -m "$mode" "$source_file" "$target_file" + log "已创建配置:${target_file}" +} + +# 执行服务器初始化 +main() { + [[ "$EUID" -eq 0 ]] || fail "请使用 sudo 执行此脚本" + [[ -n "$DEPLOY_USER" ]] || fail "请通过 DEPLOY_USER 指定服务器部署用户" + id "$DEPLOY_USER" >/dev/null 2>&1 || fail "部署用户不存在:${DEPLOY_USER}" + + require_command docker + require_command flock + docker compose version >/dev/null 2>&1 || fail "需要安装 Docker Compose v2" + docker info >/dev/null 2>&1 || fail "Docker Engine 当前不可用" + + local deploy_uid + local deploy_gid + deploy_uid="$(id -u "$DEPLOY_USER")" + deploy_gid="$(id -g "$DEPLOY_USER")" + + install -d -m 0755 -o "$deploy_uid" -g "$deploy_gid" "$SOLVIFY_ROOT" + install -d -m 0755 -o "$deploy_uid" -g "$deploy_gid" "$SOLVIFY_ROOT/releases" + install -d -m 0750 -o "$deploy_uid" -g "$deploy_gid" "$SOLVIFY_ROOT/shared" + install -d -m 0750 -o 10001 -g 10001 "$SOLVIFY_ROOT/shared/data" + install -d -m 0750 -o 10001 -g 10001 "$SOLVIFY_ROOT/shared/logs" + + install_if_missing \ + "$SCRIPT_DIR/.env.production.example" \ + "$SOLVIFY_ROOT/shared/.env" \ + 0600 + chown "$deploy_uid:$deploy_gid" "$SOLVIFY_ROOT/shared/.env" + + install_if_missing \ + "$SCRIPT_DIR/config.production.yaml.example" \ + "$SOLVIFY_ROOT/shared/config.yaml" \ + 0640 + chown "$deploy_uid:10001" "$SOLVIFY_ROOT/shared/config.yaml" + + if getent group docker >/dev/null 2>&1 && ! id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx docker; then + usermod -aG docker "$DEPLOY_USER" + log "已将 ${DEPLOY_USER} 加入 docker 用户组,请重新登录服务器后再部署" + fi + + log "服务器目录初始化完成:${SOLVIFY_ROOT}" + log "请编辑 ${SOLVIFY_ROOT}/shared/.env" + log "请编辑 ${SOLVIFY_ROOT}/shared/config.yaml" +} + +main "$@" + diff --git a/design/vue/.dockerignore b/design/vue/.dockerignore new file mode 100644 index 0000000..a3b9a51 --- /dev/null +++ b/design/vue/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.env +.env.* +*.log +.idea +.vscode + diff --git a/design/vue/Dockerfile b/design/vue/Dockerfile new file mode 100644 index 0000000..8089aaa --- /dev/null +++ b/design/vue/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 + +ARG NODE_VERSION=22 + +FROM node:${NODE_VERSION}-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.28-alpine AS runtime + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \ + CMD wget -q -O /dev/null http://127.0.0.1/health || exit 1 + diff --git a/design/vue/nginx.conf b/design/vue/nginx.conf new file mode 100644 index 0000000..9fc0b26 --- /dev/null +++ b/design/vue/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name _; + server_tokens off; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 110m; + + location = /health { + proxy_pass http://backend:8080/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|webp|woff2?)$ { + expires 7d; + add_header Cache-Control "public, max-age=604800, immutable"; + try_files $uri =404; + } +} + diff --git a/internal/integration/dingtalk/client_test.go b/internal/integration/dingtalk/client_test.go index 6543614..48bc6ee 100644 --- a/internal/integration/dingtalk/client_test.go +++ b/internal/integration/dingtalk/client_test.go @@ -217,7 +217,7 @@ func TestClientGetCurrentUserInfoUsesUserToken(t *testing.T) { if r.Header.Get("x-acs-dingtalk-access-token") != "user-token" { t.Fatalf("用户信息接口未使用个人 token Header") } - _, _ = w.Write([]byte(`{"nick":"张三","avatarUrl":"https://example.com/a.png","openId":"open-1","unionId":"union-1","email":"a@example.com"}`)) + _, _ = w.Write([]byte(`{"nick":"张三","avatarUrl":"https://example.com/architecture.png","openId":"open-1","unionId":"union-1","email":"a@example.com"}`)) })) defer server.Close() From 75f9b52cdd7c2af2dd7ca9a25403c61cf625f85f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E9=98=B3=20=E9=BE=9A?= <2302635277@qq.com> Date: Sat, 1 Aug 2026 17:47:42 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20GitHub=20Actio?= =?UTF-8?q?ns=E3=80=81GHCR=20=E5=92=8C=20Docker=20Compose=20=E7=9A=84?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=20CI/CD=20=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=B5=81=E7=A8=8B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.yaml.example | 1 + 1 file changed, 1 insertion(+) diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 1017a3c..b6876d8 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -63,6 +63,7 @@ embedding: model: text-embedding-v4 api_key: "" base_url: "" + batch_size: 10 dimension: 1024 # RAG 检索配置