From 59275ed4dc96df524b6eab0fad2a0b84bbcec340 Mon Sep 17 00:00:00 2001 From: bujie9527 Date: Thu, 5 Feb 2026 16:36:32 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E6=B5=BC=E4=BD=B7=E7=AC=9F?= =?UTF-8?q?=E5=AF=B0=EE=86=BB=E4=BF=8A=20AI=20=E9=8F=88=E5=93=84=E6=AB=92?= =?UTF-8?q?=E6=B5=9C=E5=93=84=E5=A7=AA=E9=90=9E=3FMVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 33 + .env.prod.example | 39 ++ .github-config.example | 29 + .github/workflows/build-deploy.yml | 93 +++ .github/workflows/deploy.yml | 178 +++++ .gitignore | 57 ++ README.md | 139 ++++ admin/.env.local.example | 2 + admin/.npmrc | 5 + admin/Dockerfile | 18 + admin/app/(main)/dashboard/page.tsx | 39 ++ admin/app/(main)/kb/page.tsx | 59 ++ admin/app/(main)/layout.tsx | 50 ++ admin/app/(main)/sessions/[id]/page.tsx | 90 +++ admin/app/(main)/sessions/page.tsx | 46 ++ admin/app/(main)/settings/page.tsx | 59 ++ admin/app/(main)/tickets/page.tsx | 70 ++ admin/app/(main)/users/page.tsx | 118 ++++ admin/app/AntdProvider.tsx | 12 + admin/app/globals.css | 7 + admin/app/layout.tsx | 17 + admin/app/login/page.tsx | 65 ++ admin/app/page.tsx | 16 + admin/lib/api.ts | 157 +++++ admin/next-env.d.ts | 2 + admin/next.config.ts | 7 + admin/package.json | 24 + admin/public/.gitkeep | 0 admin/tsconfig.json | 21 + backend/Dockerfile | 11 + backend/alembic.ini | 40 ++ backend/alembic/env.py | 45 ++ backend/alembic/script.py.mako | 26 + .../versions/001_users_and_audit_logs.py | 52 ++ backend/alembic/versions/002_stamp.py | 26 + .../versions/003_add_missing_columns.py | 36 + .../004_chat_sessions_and_messages.py | 59 ++ backend/app/__init__.py | 1 + backend/app/config.py | 28 + backend/app/database.py | 27 + backend/app/deps.py | 30 + backend/app/logging_config.py | 49 ++ backend/app/main.py | 68 ++ backend/app/models/__init__.py | 7 + backend/app/models/audit_log.py | 23 + backend/app/models/base.py | 5 + backend/app/models/message.py | 18 + backend/app/models/session.py | 19 + backend/app/models/ticket.py | 17 + backend/app/models/user.py | 22 + backend/app/routers/__init__.py | 2 + backend/app/routers/admin/__init__.py | 1 + backend/app/routers/admin/kb.py | 31 + backend/app/routers/admin/sessions.py | 40 ++ backend/app/routers/admin/settings.py | 37 + backend/app/routers/admin/tickets.py | 52 ++ backend/app/routers/admin/users.py | 74 ++ backend/app/routers/auth.py | 47 ++ backend/app/routers/health.py | 15 + backend/app/routers/knowledge.py | 12 + backend/app/routers/sessions.py | 53 ++ backend/app/routers/settings.py | 15 + backend/app/routers/tickets.py | 67 ++ backend/app/routers/wecom.py | 163 +++++ backend/app/services/__init__.py | 1 + backend/app/services/auth_service.py | 39 ++ backend/app/services/session_service.py | 30 + backend/app/services/wecom_api.py | 54 ++ backend/app/services/wecom_crypto.py | 119 ++++ backend/config.py | 26 + backend/pyproject.toml | 4 + backend/requirements.txt | 22 + backend/tests/__init__.py | 1 + backend/tests/test_auth.py | 26 + backend/tests/test_health.py | 16 + backend/tests/test_wecom.py | 26 + deploy/ci/github-actions.yml | 95 +++ deploy/docker/admin.Dockerfile | 52 ++ deploy/docker/backend.Dockerfile | 46 ++ deploy/docker/nginx.conf | 138 ++++ deploy/nginx-ssl.conf | 59 ++ deploy/nginx.conf | 36 + deploy/scripts/acceptance.sh | 32 + deploy/scripts/deploy-minimal.sh | 88 +++ deploy/scripts/fix_alembic_version.py | 52 ++ deploy/scripts/fix_users_table.py | 58 ++ deploy/scripts/migrate.ps1 | 31 + deploy/scripts/migrate.py | 72 ++ deploy/scripts/migrate.sh | 27 + deploy/scripts/seed.py | 69 ++ deploy/scripts/setup-ssl.sh | 67 ++ deploy/scripts/start.sh | 55 ++ deploy/scripts/stop.sh | 18 + deploy/scripts/update.sh | 64 ++ docker-compose.minimal.yml | 60 ++ docker-compose.prod.yml | 94 +++ docker-compose.yml | 53 ++ docs/cloudflared-quickstart.md | 151 ++++ docs/cloudflared-setup.md | 312 +++++++++ docs/deploy-cloud-minimal.md | 415 +++++++++++ docs/deploy-quick-reference.md | 106 +++ docs/deploy-quickstart.md | 130 ++++ docs/deploy.md | 655 ++++++++++++++++++ docs/docker-mirror.md | 62 ++ docs/github-config-guide.md | 168 +++++ docs/github-quickstart.md | 235 +++++++ docs/github-setup-guide.md | 350 ++++++++++ docs/phase1.md | 51 ++ docs/phase2.md | 50 ++ docs/phase3.md | 35 + docs/phase4.md | 18 + docs/phase5.md | 18 + docs/phase6.md | 17 + docs/phase7.md | 35 + docs/setup-steps.md | 240 +++++++ docs/stage1.md | 34 + docs/stage2.md | 160 +++++ docs/stage3.md | 38 + docs/wecom-test-guide.md | 544 +++++++++++++++ docs/wecom-test.md | 193 ++++++ docs/wecom.md | 243 +++++++ install-cloudflared.ps1 | 80 +++ scripts/push-to-github.ps1 | 90 +++ scripts/setup-github-from-config.ps1 | 162 +++++ scripts/setup-github.ps1 | 162 +++++ setup-cloudflared.ps1 | 66 ++ 126 files changed, 9120 insertions(+) create mode 100644 .env.example create mode 100644 .env.prod.example create mode 100644 .github-config.example create mode 100644 .github/workflows/build-deploy.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 admin/.env.local.example create mode 100644 admin/.npmrc create mode 100644 admin/Dockerfile create mode 100644 admin/app/(main)/dashboard/page.tsx create mode 100644 admin/app/(main)/kb/page.tsx create mode 100644 admin/app/(main)/layout.tsx create mode 100644 admin/app/(main)/sessions/[id]/page.tsx create mode 100644 admin/app/(main)/sessions/page.tsx create mode 100644 admin/app/(main)/settings/page.tsx create mode 100644 admin/app/(main)/tickets/page.tsx create mode 100644 admin/app/(main)/users/page.tsx create mode 100644 admin/app/AntdProvider.tsx create mode 100644 admin/app/globals.css create mode 100644 admin/app/layout.tsx create mode 100644 admin/app/login/page.tsx create mode 100644 admin/app/page.tsx create mode 100644 admin/lib/api.ts create mode 100644 admin/next-env.d.ts create mode 100644 admin/next.config.ts create mode 100644 admin/package.json create mode 100644 admin/public/.gitkeep create mode 100644 admin/tsconfig.json create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_users_and_audit_logs.py create mode 100644 backend/alembic/versions/002_stamp.py create mode 100644 backend/alembic/versions/003_add_missing_columns.py create mode 100644 backend/alembic/versions/004_chat_sessions_and_messages.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/deps.py create mode 100644 backend/app/logging_config.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/message.py create mode 100644 backend/app/models/session.py create mode 100644 backend/app/models/ticket.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/admin/__init__.py create mode 100644 backend/app/routers/admin/kb.py create mode 100644 backend/app/routers/admin/sessions.py create mode 100644 backend/app/routers/admin/settings.py create mode 100644 backend/app/routers/admin/tickets.py create mode 100644 backend/app/routers/admin/users.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/health.py create mode 100644 backend/app/routers/knowledge.py create mode 100644 backend/app/routers/sessions.py create mode 100644 backend/app/routers/settings.py create mode 100644 backend/app/routers/tickets.py create mode 100644 backend/app/routers/wecom.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/session_service.py create mode 100644 backend/app/services/wecom_api.py create mode 100644 backend/app/services/wecom_crypto.py create mode 100644 backend/config.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_wecom.py create mode 100644 deploy/ci/github-actions.yml create mode 100644 deploy/docker/admin.Dockerfile create mode 100644 deploy/docker/backend.Dockerfile create mode 100644 deploy/docker/nginx.conf create mode 100644 deploy/nginx-ssl.conf create mode 100644 deploy/nginx.conf create mode 100644 deploy/scripts/acceptance.sh create mode 100644 deploy/scripts/deploy-minimal.sh create mode 100644 deploy/scripts/fix_alembic_version.py create mode 100644 deploy/scripts/fix_users_table.py create mode 100644 deploy/scripts/migrate.ps1 create mode 100644 deploy/scripts/migrate.py create mode 100644 deploy/scripts/migrate.sh create mode 100644 deploy/scripts/seed.py create mode 100644 deploy/scripts/setup-ssl.sh create mode 100644 deploy/scripts/start.sh create mode 100644 deploy/scripts/stop.sh create mode 100644 deploy/scripts/update.sh create mode 100644 docker-compose.minimal.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/cloudflared-quickstart.md create mode 100644 docs/cloudflared-setup.md create mode 100644 docs/deploy-cloud-minimal.md create mode 100644 docs/deploy-quick-reference.md create mode 100644 docs/deploy-quickstart.md create mode 100644 docs/deploy.md create mode 100644 docs/docker-mirror.md create mode 100644 docs/github-config-guide.md create mode 100644 docs/github-quickstart.md create mode 100644 docs/github-setup-guide.md create mode 100644 docs/phase1.md create mode 100644 docs/phase2.md create mode 100644 docs/phase3.md create mode 100644 docs/phase4.md create mode 100644 docs/phase5.md create mode 100644 docs/phase6.md create mode 100644 docs/phase7.md create mode 100644 docs/setup-steps.md create mode 100644 docs/stage1.md create mode 100644 docs/stage2.md create mode 100644 docs/stage3.md create mode 100644 docs/wecom-test-guide.md create mode 100644 docs/wecom-test.md create mode 100644 docs/wecom.md create mode 100644 install-cloudflared.ps1 create mode 100644 scripts/push-to-github.ps1 create mode 100644 scripts/setup-github-from-config.ps1 create mode 100644 scripts/setup-github.ps1 create mode 100644 setup-cloudflared.ps1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..028999a --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# ============ Backend ============ +API_HOST=0.0.0.0 +API_PORT=8000 + +# Database (PostgreSQL) +DATABASE_URL=postgresql+asyncpg://wecom:wecom_secret@db:5432/wecom_ai +DATABASE_URL_SYNC=postgresql://wecom:wecom_secret@db:5432/wecom_ai + +# JWT (admin login) +JWT_SECRET=your-jwt-secret-change-in-production +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=60 + +# Seed 脚本用(可选,默认 admin / admin) +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin + +# WeCom (占位,后续阶段使用) +WECOM_CORP_ID= +WECOM_AGENT_ID= +WECOM_SECRET= +WECOM_TOKEN= +WECOM_ENCODING_AES_KEY= +WECOM_API_BASE=https://qyapi.weixin.qq.com +WECOM_API_TIMEOUT=10 +WECOM_API_RETRIES=2 + +# Log +LOG_LEVEL=INFO +LOG_JSON=true + +# ============ Admin (Next.js) ============ +NEXT_PUBLIC_API_BASE=http://localhost:8000 diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..7b21583 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,39 @@ +# 生产环境变量模板 +# 复制此文件为 .env.prod 并填写实际值 +# 注意:.env.prod 包含敏感信息,不要提交到 Git 仓库 + +# ============ Backend ============ +API_HOST=0.0.0.0 +API_PORT=8000 + +# Database(可选,最小回调壳可以先不启用) +# DATABASE_URL=postgresql+asyncpg://wecom:wecom_secret@db:5432/wecom_ai +# DATABASE_URL_SYNC=postgresql://wecom:wecom_secret@db:5432/wecom_ai + +# JWT(admin 登录,可选) +JWT_SECRET=your-jwt-secret-change-in-production +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=60 + +# WeCom Callback(必须,从企业微信管理后台获取) +WECOM_CORP_ID=你的企业ID +WECOM_AGENT_ID=你的应用AgentId +WECOM_SECRET=你的应用Secret(可选,用于主动发送消息) +WECOM_TOKEN=你的Token(必须与企微后台一致) +WECOM_ENCODING_AES_KEY=你的43位密钥(必须与企微后台一致) + +# WeCom API +WECOM_API_BASE=https://qyapi.weixin.qq.com +WECOM_API_TIMEOUT=10 +WECOM_API_RETRIES=2 + +# Log +LOG_LEVEL=INFO +LOG_JSON=true + +# ============ Admin (Next.js) ============ +# NEXT_PUBLIC_API_BASE=https://your-domain.com + +# ============ Nginx (deploy) ============ +# SSL_CERT_PATH=/etc/letsencrypt/live/your-domain.com/fullchain.pem +# SSL_KEY_PATH=/etc/letsencrypt/live/your-domain.com/privkey.pem diff --git a/.github-config.example b/.github-config.example new file mode 100644 index 0000000..449ae92 --- /dev/null +++ b/.github-config.example @@ -0,0 +1,29 @@ +# GitHub 配置文件模板 +# 复制此文件为 .github-config(不提交到 Git) +# 用途:本地开发和自动化脚本使用 + +# GitHub 用户名/组织 +GITHUB_USERNAME=bujie9527 + +# GitHub Personal Access Token +# 获取方式:https://github.com/settings/tokens +# 权限:repo, write:packages, read:packages +# ⚠️ 警告:此 token 具有仓库访问权限,请妥善保管,不要提交到 Git! +GITHUB_TOKEN=your_token_here + +# GitHub 仓库名称(如果与项目名不同) +GITHUB_REPO_NAME=wecom-ai-assistant + +# GitHub 仓库完整 URL +GITHUB_REPO_URL=https://github.com/bujie9527/wecom-ai-assistant.git + +# 默认分支 +GITHUB_DEFAULT_BRANCH=main + +# Container Registry 配置 +GHCR_REGISTRY=ghcr.io +GHCR_IMAGE_PREFIX=bujie9527 + +# 镜像名称 +BACKEND_IMAGE_NAME=wecom-ai-backend +ADMIN_IMAGE_NAME=wecom-ai-admin diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 0000000..4524700 --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,93 @@ +# 推送 main 时构建并发布(镜像推送 + 后端测试) +name: Build and Deploy + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + +jobs: + test-backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: wecom + POSTGRES_PASSWORD: wecom_secret + POSTGRES_DB: wecom_ai + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql+asyncpg://wecom:wecom_secret@localhost:5432/wecom_ai + DATABASE_URL_SYNC: postgresql://wecom:wecom_secret@localhost:5432/wecom_ai + JWT_SECRET: test-secret + WECOM_TOKEN: test + WECOM_ENCODING_AES_KEY: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + cd backend + pip install -r requirements.txt + - name: Run migrations + run: | + cd backend && PYTHONPATH=. alembic upgrade head + - name: Pytest + run: | + cd backend && PYTHONPATH=. pytest tests/ -v --tb=short + + build-backend: + runs-on: ubuntu-latest + needs: test-backend + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push backend + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-backend:latest + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-backend:${{ github.sha }} + + build-admin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push admin + uses: docker/build-push-action@v6 + with: + context: ./admin + file: ./admin/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-admin:latest + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-admin:${{ github.sha }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e6020f6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,178 @@ +# 生产环境自动部署 +# 触发条件:push 到 main 分支 +# 功能:构建 backend 镜像 → 推送到 GHCR → SSH 部署到云服务器 → 健康检查 + +name: Deploy to Production + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + image_tag: + description: '镜像标签(默认: latest)' + required: false + default: 'latest' + +env: + REGISTRY: ghcr.io + IMAGE_NAME_BACKEND: wecom-ai-backend + +jobs: + # 构建并推送 Backend 镜像 + build-backend: + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.tags }} + image_digest: ${{ steps.build.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME_BACKEND }} + tags: | + type=raw,value=latest + type=sha,prefix={{branch}}- + type=sha,format=short + + - name: Build and push backend image + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ./deploy/docker/backend.Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # 部署到生产服务器 + deploy: + runs-on: ubuntu-latest + needs: build-backend + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set deployment variables + id: vars + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "image_tag=${{ github.event.inputs.image_tag }}" >> $GITHUB_OUTPUT + else + echo "image_tag=latest" >> $GITHUB_OUTPUT + fi + echo "domain=${{ secrets.PROD_DOMAIN }}" >> $GITHUB_OUTPUT + + - name: Prepare deployment token + id: prepare-token + run: | + if [ -n "${{ secrets.GHCR_TOKEN }}" ]; then + echo "token=${{ secrets.GHCR_TOKEN }}" >> $GITHUB_OUTPUT + else + echo "token=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_OUTPUT + fi + + - name: Deploy to production server + uses: appleboy/ssh-action@v1.0.0 + env: + DEPLOY_TOKEN: ${{ steps.prepare-token.outputs.token }} + with: + host: ${{ secrets.PROD_HOST }} + username: ${{ secrets.PROD_USER }} + key: ${{ secrets.PROD_SSH_KEY }} + port: ${{ secrets.PROD_SSH_PORT || 22 }} + script: | + set -e + + # 设置变量 + IMAGE_TAG="${{ steps.vars.outputs.image_tag }}" + GITHUB_REPOSITORY_OWNER="${{ github.repository_owner }}" + REGISTRY="${{ env.REGISTRY }}" + IMAGE_NAME_BACKEND="${{ env.IMAGE_NAME_BACKEND }}" + APP_PATH="${{ secrets.PROD_APP_PATH || '/opt/wecom-ai-assistant' }}" + + # 进入项目目录 + cd "${APP_PATH}" || { echo "错误: 无法进入目录 ${APP_PATH}"; exit 1; } + + # 登录到容器镜像仓库 + echo "${DEPLOY_TOKEN}" | docker login "${REGISTRY}" -u "${{ github.actor }}" --password-stdin || { + echo "警告: Docker 登录失败,尝试继续部署(可能使用本地镜像)" + } + + # 拉取最新镜像 + IMAGE_FULL="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME_BACKEND}:${IMAGE_TAG}" + echo "拉取镜像: ${IMAGE_FULL}" + docker pull "${IMAGE_FULL}" || { + echo "警告: 拉取镜像失败,尝试使用 latest 标签" + docker pull "${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME_BACKEND}:latest" + IMAGE_TAG="latest" + } + + # 设置环境变量供 docker-compose 使用 + export IMAGE_TAG="${IMAGE_TAG}" + export GITHUB_REPOSITORY_OWNER="${GITHUB_REPOSITORY_OWNER}" + + # 更新服务 + echo "更新服务..." + docker compose -f docker-compose.prod.yml --env-file .env.prod pull backend 2>/dev/null || true + docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --force-recreate backend + + # 等待服务启动 + echo "等待服务启动..." + sleep 15 + + # 检查服务状态 + echo "服务状态:" + docker compose -f docker-compose.prod.yml ps + + # 检查后端健康状态 + echo "检查后端健康状态..." + for i in {1..10}; do + if docker compose -f docker-compose.prod.yml exec -T backend python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" 2>/dev/null; then + echo "✓ 后端服务健康" + break + fi + if [ $i -eq 10 ]; then + echo "⚠ 后端服务可能未就绪,请检查日志" + else + sleep 2 + fi + done + + - name: Health check + run: | + DOMAIN=${{ secrets.PROD_DOMAIN }} + MAX_RETRIES=30 + RETRY_COUNT=0 + + echo "健康检查: https://${DOMAIN}/api/health" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -f -s "https://${DOMAIN}/api/health" > /dev/null 2>&1; then + echo "✓ 健康检查通过" + exit 0 + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "等待服务就绪... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 2 + done + + echo "✗ 健康检查失败" + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3832ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Env +.env +.env.local +.env.*.local +.env.prod + +# GitHub Config (包含敏感 token) +.github-config +github-actions.key + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ + +# Node +node_modules/ +.next/ +out/ +.nuxt/ +dist/ +*.tsbuildinfo + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# DB +*.db +*.sqlite3 + +# Docker +.docker/ + +# Test +.coverage +htmlcov/ +.pytest_cache/ + +# Upload / local storage +backend/uploads/ +backend/storage/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac0f7bc --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# 企业微信 AI 机器人助理 + +Monorepo:`backend/` `admin/` `deploy/` `docs/` + +## 本地启动 + +1. **复制环境变量** + ```bash + cp .env.example .env + ``` + Windows PowerShell: + ```powershell + Copy-Item .env.example .env + ``` + +2. **一键启动** + ```bash + docker compose up -d + ``` + +3. **访问地址** + - 管理后台(经 nginx):http://localhost + - 管理后台(直连):http://localhost:3000 + - 后端 API(经 nginx):http://localhost/api/health + - 后端 API(直连):http://localhost:8000/api/health + - PostgreSQL:localhost:5432(仅本机连接) + +## 端口说明 + +| 服务 | 端口 | 说明 | +|---------|-------|----------------| +| nginx | 80 | 反代,/api→backend,其余→admin | +| admin | 3000 | Next.js | +| backend | 8000 | FastAPI | +| db | 5432 | PostgreSQL | + +## 阶段 2:登录与管理员 + +- 启动后 backend 会自动执行 `alembic upgrade head`(创建 users、audit_logs 表)。 +- 创建管理员:在项目根目录执行 `python deploy/scripts/seed.py`(依赖见 `docs/stage2.md`)。 +- 后台登录:打开 http://localhost → 登录页,token 存于 **localStorage**(key: `token`),详见 `admin/lib/api.ts` 与 `docs/stage2.md`。 + +## 阶段 1 验证 + +- `GET http://localhost/api/health` 或 `GET http://localhost:8000/api/health` 返回占位 JSON +- `GET http://localhost/api/ready` 或 `GET http://localhost:8000/api/ready` 返回占位 JSON +- 浏览器打开 http://localhost 或 http://localhost:3000,可访问 /login、/dashboard 占位页 + +## 云端最小回调壳部署 + +**重要**:企业微信回调域名必须备案且主体关联,必须先完成云端最小可用部署。 + +### 方式一:GitHub Actions 自动部署(推荐) + +#### 快速开始(使用配置文件) + +1. **使用配置文件自动设置**: + ```powershell + # 配置文件已创建:.github-config(包含你的 GitHub 信息) + # 自动配置 Git 远程仓库 + .\scripts\setup-github-from-config.ps1 + + # 推送代码到 GitHub + .\scripts\push-to-github.ps1 + ``` + +#### 手动设置 + +1. **创建 GitHub 仓库并推送代码**: + ```powershell + git init + git add . + git commit -m "Initial commit" + git remote add origin https://github.com/bujie9527/wecom-ai-assistant.git + git push -u origin main + ``` + +2. **配置 GitHub Secrets**: + - 进入 GitHub 仓库 → Settings → Secrets and variables → Actions + - 添加以下 Secrets: + - `PROD_HOST`: 生产服务器 IP + - `PROD_USER`: SSH 用户名 + - `PROD_SSH_KEY`: SSH 私钥 + - `PROD_DOMAIN`: 生产域名 + - `PROD_APP_PATH`: 应用部署路径(可选,默认 `/opt/wecom-ai-assistant`) + +3. **在生产服务器上准备**: + ```bash + # 安装 Docker + sudo apt-get update && sudo apt-get install -y docker.io docker-compose-plugin + + # 克隆项目 + sudo mkdir -p /opt/wecom-ai-assistant + cd /opt/wecom-ai-assistant + git clone https://github.com/YOUR_USERNAME/wecom-ai-assistant.git . + + # 配置环境变量 + cp .env.prod.example .env.prod + nano .env.prod # 填写生产环境变量 + ``` + +4. **触发部署**: + - 推送代码到 `main` 分支,GitHub Actions 会自动部署 + - 或手动触发:Actions → Deploy to Production → Run workflow + +**详细文档**:参见 `docs/github-setup-guide.md` + +### 方式二:手动部署 + +1. **准备环境**: + - 备案域名(例如:`api.yourdomain.com`) + - Linux 服务器(Ubuntu 20.04+ / CentOS 7+) + - Docker + docker-compose + +2. **配置环境变量**: + ```bash + cp .env.prod.example .env.prod + # 编辑 .env.prod,填写必需变量(WECOM_TOKEN、WECOM_ENCODING_AES_KEY 等) + ``` + +3. **部署最小回调壳**: + ```bash + # 使用生产配置(backend + nginx) + docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d + ``` + +4. **配置 HTTPS**(Let's Encrypt): + ```bash + export DOMAIN=your-domain.com + export SSL_EMAIL=your-email@example.com + bash deploy/scripts/setup-ssl.sh + ``` + +5. **配置企业微信回调**: + - 回调 URL:`https://your-domain.com/api/wecom/callback` + - Token:与 `.env.prod` 中的 `WECOM_TOKEN` 一致 + - EncodingAESKey:与 `.env.prod` 中的 `WECOM_ENCODING_AES_KEY` 一致 + +**详细文档**:参见 `docs/deploy.md` 和 `docs/deploy-cloud-minimal.md` diff --git a/admin/.env.local.example b/admin/.env.local.example new file mode 100644 index 0000000..d344e12 --- /dev/null +++ b/admin/.env.local.example @@ -0,0 +1,2 @@ +# 复制为 .env.local 后前端会读取;直连后端时填下面一行 +NEXT_PUBLIC_API_BASE=http://localhost:8000 diff --git a/admin/.npmrc b/admin/.npmrc new file mode 100644 index 0000000..f0f76f1 --- /dev/null +++ b/admin/.npmrc @@ -0,0 +1,5 @@ +legacy-peer-deps=true +optional=true +prefer-offline=false +fetch-retries=5 +fetch-timeout=60000 diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..fec2027 --- /dev/null +++ b/admin/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package.json package-lock.json* .npmrc* ./ +RUN npm config set registry https://registry.npmmirror.com +RUN npm install --legacy-peer-deps +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/admin/app/(main)/dashboard/page.tsx b/admin/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..25efed1 --- /dev/null +++ b/admin/app/(main)/dashboard/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, Row, Col, Statistic } from "antd"; +import { getMe, listSessions, listTickets } from "@/lib/api"; + +export default function DashboardPage() { + const [user, setUser] = useState(null); + const [stats, setStats] = useState({ sessions: 0, tickets: 0 }); + + useEffect(() => { + getMe().then(setUser).catch(() => {}); + listSessions().then((r) => r.data && setStats((s) => ({ ...s, sessions: r.data?.length || 0 }))).catch(() => {}); + listTickets().then((r) => r.data && setStats((s) => ({ ...s, tickets: r.data?.length || 0 }))).catch(() => {}); + }, []); + + return ( +
+

总览

+ + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/admin/app/(main)/kb/page.tsx b/admin/app/(main)/kb/page.tsx new file mode 100644 index 0000000..8d45233 --- /dev/null +++ b/admin/app/(main)/kb/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, Upload, Table, message } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { InboxOutlined } from "@ant-design/icons"; +import { listKbDocs, uploadKnowledge } from "@/lib/api"; + +type Doc = { id: string; filename: string; size: number; uploaded_at: string }; + +export default function KbPage() { + const [docs, setDocs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + listKbDocs() + .then((res) => { + if (res.code === 0 && res.data) setDocs(res.data); + }) + .catch(() => {}); + }, []); + + const beforeUpload = async (file: File) => { + setLoading(true); + try { + const res = await uploadKnowledge(file); + if (res.code === 0) { + message.success(`已上传: ${res.data?.filename ?? file.name}`); + listKbDocs().then((r) => r.code === 0 && r.data && setDocs(r.data)); + } else message.error(res.message || "上传失败"); + } finally { + setLoading(false); + } + return false; + }; + + const columns: ColumnsType = [ + { title: "文件名", dataIndex: "filename", ellipsis: true }, + { title: "大小", dataIndex: "size", width: 100, render: (s) => `${(s / 1024).toFixed(2)} KB` }, + { title: "上传时间", dataIndex: "uploaded_at", width: 180 }, + ]; + + return ( +
+ + +

+ +

+

点击或拖拽文件到此区域上传

+

当前为占位,文件将落本地/对象存储

+
+
+ + + + + ); +} diff --git a/admin/app/(main)/layout.tsx b/admin/app/(main)/layout.tsx new file mode 100644 index 0000000..6b7bfe6 --- /dev/null +++ b/admin/app/(main)/layout.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import { useEffect } from "react"; +import { Layout, Menu, Button } from "antd"; +import Link from "next/link"; + +const { Header, Content } = Layout; + +export default function MainLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (typeof window !== "undefined" && !localStorage.getItem("token")) { + router.replace("/login"); + } + }, [router]); + + const logout = () => { + localStorage.removeItem("token"); + router.push("/login"); + }; + + return ( + +
+
企微AI助手
+ 总览 }, + { key: "/sessions", label: 会话列表 }, + { key: "/tickets", label: 工单列表 }, + { key: "/kb", label: 知识库 }, + { key: "/settings", label: 设置 }, + { key: "/users", label: 用户管理 }, + ]} + /> + +
+ {children} +
+ ); +} diff --git a/admin/app/(main)/sessions/[id]/page.tsx b/admin/app/(main)/sessions/[id]/page.tsx new file mode 100644 index 0000000..b3ac7ad --- /dev/null +++ b/admin/app/(main)/sessions/[id]/page.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { Card, List, Input, Button, message, Space } from "antd"; +import { getSession, createTicket } from "@/lib/api"; + +type Msg = { id: number; role: string; content: string; created_at: string }; + +export default function SessionDetailPage() { + const params = useParams(); + const id = String(params?.id ?? ""); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [reply, setReply] = useState(""); + const [sending, setSending] = useState(false); + const [ticketLoading, setTicketLoading] = useState(false); + + useEffect(() => { + if (!id) return; + getSession(id) + .then((res) => { + if (res.code === 0 && res.data) { + setMessages(res.data.messages || []); + } else { + message.error(res.message || "加载失败"); + } + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, [id]); + + const onSendReply = async () => { + if (!reply.trim()) return; + setSending(true); + try { + message.info("发送功能待实现(需接入企微发消息 API)"); + setReply(""); + } finally { + setSending(false); + } + }; + + const onCreateTicket = async () => { + setTicketLoading(true); + try { + const res = await createTicket(id, "转人工"); + if (res.code === 0) message.success("工单已创建"); + else message.error(res.message || "创建失败"); + } finally { + setTicketLoading(false); + } + }; + + return ( + + + 转人工/创建工单 + + } + > + ( + + + + )} + /> + + setReply(e.target.value)} + onPressEnter={onSendReply} + /> + + + + + ); +} diff --git a/admin/app/(main)/sessions/page.tsx b/admin/app/(main)/sessions/page.tsx new file mode 100644 index 0000000..4feded6 --- /dev/null +++ b/admin/app/(main)/sessions/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Table, Card, message } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { listSessions } from "@/lib/api"; +import Link from "next/link"; + +type Row = { id: string; external_user_id: string; external_name?: string; status: string; created_at: string }; + +export default function SessionsPage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + listSessions() + .then((res) => { + if (res.code === 0 && res.data) setList(res.data); + else message.error(res.message || "加载失败"); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, []); + + const columns: ColumnsType = [ + { title: "ID", dataIndex: "id", width: 80 }, + { title: "外部用户ID", dataIndex: "external_user_id", ellipsis: true }, + { title: "昵称", dataIndex: "external_name" }, + { title: "状态", dataIndex: "status", width: 100 }, + { title: "创建时间", dataIndex: "created_at", width: 180 }, + { + title: "操作", + key: "action", + width: 100, + render: (_, r) => 查看消息, + }, + ]; + + return ( + +
+ + ); +} diff --git a/admin/app/(main)/settings/page.tsx b/admin/app/(main)/settings/page.tsx new file mode 100644 index 0000000..ba3f4e1 --- /dev/null +++ b/admin/app/(main)/settings/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, Form, Input, Switch, Button, message } from "antd"; +import { getSettings, updateSettings } from "@/lib/api"; + +export default function SettingsPage() { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + getSettings() + .then((res) => { + if (res.code === 0 && res.data) { + form.setFieldsValue({ + model_name: res.data.model_name, + faq_priority: res.data.strategy?.faq_priority, + rag_enabled: res.data.strategy?.rag_enabled, + }); + } + }) + .catch(() => {}); + }, [form]); + + const onFinish = async (v: { model_name: string; faq_priority: boolean; rag_enabled: boolean }) => { + setLoading(true); + try { + const res = await updateSettings(v.model_name, { + faq_priority: v.faq_priority, + rag_enabled: v.rag_enabled, + }); + if (res.code === 0) message.success("设置已保存"); + else message.error(res.message || "保存失败"); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + + + + + + + + + + + + +
+ ); +} diff --git a/admin/app/(main)/tickets/page.tsx b/admin/app/(main)/tickets/page.tsx new file mode 100644 index 0000000..841b351 --- /dev/null +++ b/admin/app/(main)/tickets/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Table, Card, Button, Tag, message } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { listTickets, updateTicket } from "@/lib/api"; +import Link from "next/link"; + +type Row = { id: string; session_id: string; reason: string; status: string; created_at: string }; + +export default function TicketsPage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + listTickets() + .then((res) => { + if (res.code === 0 && res.data) setList(res.data); + else message.error(res.message || "加载失败"); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, []); + + const handleStatusChange = async (id: string, status: string) => { + const res = await updateTicket(id, status); + if (res.code === 0) { + message.success("已更新"); + listTickets().then((r) => r.code === 0 && r.data && setList(r.data)); + } else message.error(res.message || "更新失败"); + }; + + const columns: ColumnsType = [ + { title: "ID", dataIndex: "id", width: 80 }, + { + title: "会话", + dataIndex: "session_id", + width: 100, + render: (sid) => {sid}, + }, + { title: "原因", dataIndex: "reason", ellipsis: true }, + { + title: "状态", + dataIndex: "status", + width: 120, + render: (s) => ( + {s} + ), + }, + { title: "创建时间", dataIndex: "created_at", width: 180 }, + { + title: "操作", + key: "action", + width: 150, + render: (_, r) => ( + + ), + }, + ]; + + return ( + +
+ + ); +} diff --git a/admin/app/(main)/users/page.tsx b/admin/app/(main)/users/page.tsx new file mode 100644 index 0000000..fcf0e4a --- /dev/null +++ b/admin/app/(main)/users/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Table, Card, Button, Modal, Form, Input, Select, Switch, message, Popconfirm } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { listUsers, createUser, updateUser, deleteUser, getMe } from "@/lib/api"; + +type Row = { id: string; username: string; role: string; is_active: boolean; created_at: string }; + +export default function UsersPage() { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form] = Form.useForm(); + + useEffect(() => { + getMe() + .then((u) => setIsAdmin(u.role === "admin")) + .catch(() => {}); + listUsers() + .then((res) => { + if (res.code === 0 && res.data) setList(res.data); + else if (res.code === 403) message.error("仅管理员可访问"); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + }, []); + + const handleCreate = async (v: { username: string; password: string; role: string; is_active: boolean }) => { + const res = await createUser(v.username, v.password, v.role, v.is_active); + if (res.code === 0) { + message.success("已创建"); + setModalOpen(false); + form.resetFields(); + listUsers().then((r) => r.code === 0 && r.data && setList(r.data)); + } else message.error(res.message || "创建失败"); + }; + + const handleUpdate = async (id: string, v: { role?: string; is_active?: boolean }) => { + const res = await updateUser(id, undefined, v.role, v.is_active); + if (res.code === 0) { + message.success("已更新"); + listUsers().then((r) => r.code === 0 && r.data && setList(r.data)); + } else message.error(res.message || "更新失败"); + }; + + const handleDelete = async (id: string) => { + const res = await deleteUser(id); + if (res.code === 0) { + message.success("已删除"); + listUsers().then((r) => r.code === 0 && r.data && setList(r.data)); + } else message.error(res.message || "删除失败"); + }; + + const columns: ColumnsType = [ + { title: "ID", dataIndex: "id", width: 100, ellipsis: true }, + { title: "用户名", dataIndex: "username", width: 120 }, + { title: "角色", dataIndex: "role", width: 100 }, + { + title: "状态", + dataIndex: "is_active", + width: 100, + render: (active, r) => ( + handleUpdate(r.id, { is_active: v })} /> + ), + }, + { title: "创建时间", dataIndex: "created_at", width: 180 }, + { + title: "操作", + key: "action", + width: 120, + render: (_, r) => ( + handleDelete(r.id)}> + + + ), + }, + ]; + + if (!isAdmin) { + return 仅管理员可访问此页面。; + } + + return ( + <> + setModalOpen(true)}>新建用户}> +
+ + { setModalOpen(false); form.resetFields(); }} footer={null}> +
+ + + + + + + + setUsername(e.target.value)} + required + style={{ width: "100%", padding: 8 }} + /> + +
+ +
+ setPassword(e.target.value)} + required + style={{ width: "100%", padding: 8 }} + /> +
+ {error &&

{error}

} + + + + ); +} diff --git a/admin/app/page.tsx b/admin/app/page.tsx new file mode 100644 index 0000000..27faee1 --- /dev/null +++ b/admin/app/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function Home() { + const router = useRouter(); + useEffect(() => { + if (typeof window !== "undefined" && localStorage.getItem("token")) { + router.replace("/dashboard"); + } else { + router.replace("/login"); + } + }, [router]); + return null; +} diff --git a/admin/lib/api.ts b/admin/lib/api.ts new file mode 100644 index 0000000..beb3dfb --- /dev/null +++ b/admin/lib/api.ts @@ -0,0 +1,157 @@ +/** + * 后台 API 封装(统一 code/data/message/trace_id)。 + * Token 存储:使用 localStorage 存 key "token"(便于开发与阶段 2 验证); + * 生产环境可改为 httpOnly cookie 由后端 Set-Cookie。 + */ +const API_BASE = process.env.NEXT_PUBLIC_API_BASE || ""; + +const getToken = (): string | null => { + if (typeof window === "undefined") return null; + return localStorage.getItem("token"); +}; + +export type ApiRes = { + code: number; + message: string; + data: T | null; + trace_id?: string; +}; + +async function adminApi( + path: string, + options: Omit & { body?: object } = {} +): Promise> { + try { + const { body, ...rest } = options; + const headers: HeadersInit = { + "Content-Type": "application/json", + ...(rest.headers as Record), + }; + const token = getToken(); + if (token) headers["Authorization"] = `Bearer ${token}`; + const res = await fetch(`${API_BASE}${path}`, { + ...rest, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + const data: ApiRes = await res.json().catch(() => ({ code: -1, message: "解析失败", data: null })); + if (res.status === 401) { + if (typeof window !== "undefined") localStorage.removeItem("token"); + } + return data; + } catch (err) { + // 网络错误、CORS 错误等 + return { code: -1, message: err instanceof Error ? err.message : "网络错误", data: null }; + } +} + +// ============ Auth ============ +export async function login(username: string, password: string): Promise<{ access_token: string; token_type: string }> { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const e = await res.json().catch(() => ({})); + throw new Error((e as { detail?: string }).detail || "登录失败"); + } + return res.json(); +} + +export async function getMe(): Promise<{ + id: string; + username: string; + role: string; + is_active: boolean; + created_at: string | null; +}> { + const token = getToken(); + if (!token) throw new Error("未登录"); + const res = await fetch(`${API_BASE}/api/auth/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error("未登录或已过期"); + return res.json(); +} + +export function logout(): void { + if (typeof window !== "undefined") localStorage.removeItem("token"); +} + +// ============ Admin: Sessions ============ +export async function listSessions(): Promise>> { + return adminApi("/api/admin/sessions"); +} + +export async function getSession(id: string): Promise }>> { + return adminApi(`/api/admin/sessions/${id}`); +} + +// ============ Admin: Tickets ============ +export async function listTickets(): Promise>> { + return adminApi("/api/admin/tickets"); +} + +export async function createTicket(sessionId: string, reason?: string): Promise> { + return adminApi("/api/admin/tickets", { method: "POST", body: { session_id: sessionId, reason: reason || "" } }); +} + +export async function updateTicket(id: string, status?: string, reason?: string): Promise> { + return adminApi(`/api/admin/tickets/${id}`, { method: "PATCH", body: { status, reason } }); +} + +// ============ Admin: KB ============ +export async function listKbDocs(): Promise>> { + return adminApi("/api/admin/kb/docs"); +} + +export async function uploadKnowledge(file: File): Promise> { + try { + const token = getToken(); + const form = new FormData(); + form.append("file", file); + const res = await fetch(`${API_BASE}/api/admin/kb/docs/upload`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: form, + }); + const data: ApiRes<{ id: string; filename: string; size: number }> = await res.json().catch(() => ({ + code: -1, + message: "解析失败", + data: null, + })); + if (res.status === 401) { + if (typeof window !== "undefined") localStorage.removeItem("token"); + } + return data; + } catch (err) { + return { code: -1, message: err instanceof Error ? err.message : "网络错误", data: null }; + } +} + +// ============ Admin: Settings ============ +export async function getSettings(): Promise }>> { + return adminApi("/api/admin/settings"); +} + +export async function updateSettings(modelName?: string, strategy?: Record): Promise }>> { + return adminApi("/api/admin/settings", { method: "PATCH", body: { model_name: modelName, strategy } }); +} + +// ============ Admin: Users ============ +export async function listUsers(): Promise>> { + return adminApi("/api/admin/users"); +} + +export async function createUser(username: string, password: string, role?: string, isActive?: boolean): Promise> { + return adminApi("/api/admin/users", { method: "POST", body: { username, password, role: role || "admin", is_active: isActive !== false } }); +} + +export async function updateUser(id: string, password?: string, role?: string, isActive?: boolean): Promise> { + return adminApi(`/api/admin/users/${id}`, { method: "PATCH", body: { password, role, is_active: isActive } }); +} + +export async function deleteUser(id: string): Promise> { + return adminApi(`/api/admin/users/${id}`, { method: "DELETE" }); +} diff --git a/admin/next-env.d.ts b/admin/next-env.d.ts new file mode 100644 index 0000000..6080add --- /dev/null +++ b/admin/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/admin/next.config.ts b/admin/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/admin/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..dbcaba2 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,24 @@ +{ + "name": "wecom-admin", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "15.0.5", + "react": "19.0.0", + "react-dom": "19.0.0", + "antd": "5.22.2", + "@ant-design/icons": "5.5.1" + }, + "devDependencies": { + "@types/node": "22.10.1", + "@types/react": "19.0.1", + "@types/react-dom": "19.0.1", + "typescript": "5.7.2" + } +} diff --git a/admin/public/.gitkeep b/admin/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..e4effab --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..705fcde --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +ENV PYTHONPATH=/app +EXPOSE 8000 +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..bed1805 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..93bac2f --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,45 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config +from sqlalchemy.engine import Connection +from sqlalchemy import pool + +from app.config import settings +from app.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) +config.set_main_option("sqlalchemy.url", settings.database_url_sync) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + do_run_migrations(connection) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_users_and_audit_logs.py b/backend/alembic/versions/001_users_and_audit_logs.py new file mode 100644 index 0000000..d792125 --- /dev/null +++ b/backend/alembic/versions/001_users_and_audit_logs.py @@ -0,0 +1,52 @@ +"""users and audit_logs + +Revision ID: 001 +Revises: +Create Date: 2025-02-05 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("username", sa.String(64), nullable=False), + sa.Column("password_hash", sa.String(256), nullable=False), + sa.Column("role", sa.String(32), nullable=False, server_default="admin"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) + + op.create_table( + "audit_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("actor_user_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("action", sa.String(128), nullable=False), + sa.Column("meta_json", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_audit_logs_actor_user_id"), "audit_logs", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_audit_logs_action"), "audit_logs", ["action"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_audit_logs_action"), table_name="audit_logs") + op.drop_index(op.f("ix_audit_logs_actor_user_id"), table_name="audit_logs") + op.drop_table("audit_logs") + op.drop_index(op.f("ix_users_username"), table_name="users") + op.drop_table("users") diff --git a/backend/alembic/versions/002_stamp.py b/backend/alembic/versions/002_stamp.py new file mode 100644 index 0000000..435d842 --- /dev/null +++ b/backend/alembic/versions/002_stamp.py @@ -0,0 +1,26 @@ +"""stamp 002 (empty migration to match DB state) + +Revision ID: 002 +Revises: 001 +Create Date: 2025-02-05 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 空迁移:仅用于对齐数据库中的版本号 + pass + + +def downgrade() -> None: + # 空迁移:仅用于对齐数据库中的版本号 + pass diff --git a/backend/alembic/versions/003_add_missing_columns.py b/backend/alembic/versions/003_add_missing_columns.py new file mode 100644 index 0000000..21ef8d7 --- /dev/null +++ b/backend/alembic/versions/003_add_missing_columns.py @@ -0,0 +1,36 @@ +"""add missing columns if users table exists without them + +Revision ID: 003 +Revises: 002 +Create Date: 2025-02-05 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "003" +down_revision: Union[str, None] = "002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 检查 users 表是否存在 role 列,若不存在则添加 + conn = op.get_bind() + inspector = sa.inspect(conn) + columns = [c["name"] for c in inspector.get_columns("users")] if inspector.has_table("users") else [] + + if "users" in inspector.get_table_names(): + if "role" not in columns: + op.add_column("users", sa.Column("role", sa.String(32), nullable=False, server_default="admin")) + if "is_active" not in columns: + op.add_column("users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true")) + if "created_at" not in columns: + op.add_column("users", sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True)) + + +def downgrade() -> None: + # 可选:移除这些列(通常不需要) + pass diff --git a/backend/alembic/versions/004_chat_sessions_and_messages.py b/backend/alembic/versions/004_chat_sessions_and_messages.py new file mode 100644 index 0000000..9b036cb --- /dev/null +++ b/backend/alembic/versions/004_chat_sessions_and_messages.py @@ -0,0 +1,59 @@ +"""Create chat_sessions and messages tables. + +Revision ID: 004 +Revises: 003 +Create Date: 2025-02-05 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "004" +down_revision: Union[str, None] = "003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + from sqlalchemy import inspect + + conn = op.get_bind() + inspector = inspect(conn) + tables = inspector.get_table_names() + + if "chat_sessions" not in tables: + op.create_table( + "chat_sessions", + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("external_user_id", sa.String(128), nullable=False), + sa.Column("external_name", sa.String(128), nullable=True), + sa.Column("status", sa.String(32), nullable=False, server_default="open"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_chat_sessions_external_user_id"), "chat_sessions", ["external_user_id"], unique=False) + + if "messages" not in tables: + op.create_table( + "messages", + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("session_id", sa.Integer(), nullable=False), + sa.Column("role", sa.String(16), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["session_id"], ["chat_sessions.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_messages_session_id"), "messages", ["session_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_messages_session_id"), table_name="messages") + op.drop_table("messages") + op.drop_index(op.f("ix_chat_sessions_external_user_id"), table_name="chat_sessions") + op.drop_table("chat_sessions") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..5629210 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# backend app diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..5298c33 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,28 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + api_host: str = "0.0.0.0" + api_port: int = 8000 + database_url: str = "postgresql+asyncpg://wecom:wecom_secret@localhost:5432/wecom_ai" + database_url_sync: str = "postgresql://wecom:wecom_secret@localhost:5432/wecom_ai" + + jwt_secret: str = "change-me" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 60 + + wecom_corp_id: str = "" + wecom_agent_id: str = "" + wecom_secret: str = "" + wecom_token: str = "" + wecom_encoding_aes_key: str = "" + wecom_api_base: str = "https://qyapi.weixin.qq.com" + wecom_api_timeout: int = 10 + wecom_api_retries: int = 2 + log_level: str = "INFO" + log_json: bool = True + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..e5ebf41 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,27 @@ +"""异步数据库会话,DATABASE_URL 来自环境变量。""" +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine( + settings.database_url, + echo=False, + pool_pre_ping=True, +) +async_session_factory = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False, autoflush=False +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/deps.py b/backend/app/deps.py new file mode 100644 index 0000000..e5e8978 --- /dev/null +++ b/backend/app/deps.py @@ -0,0 +1,30 @@ +"""依赖:get_db、JWT 校验。""" +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models import User +from app.services.auth_service import decode_access_token + +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + db: AsyncSession = Depends(get_db), + credentials: HTTPAuthorizationCredentials | None = Depends(security), +) -> User: + if not credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供认证信息") + subject = decode_access_token(credentials.credentials) + if not subject: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效或已过期的 token") + # subject 存 username + r = await db.execute(select(User).where(User.username == subject)) + user = r.scalar_one_or_none() + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在") + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用") + return user diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py new file mode 100644 index 0000000..660d90d --- /dev/null +++ b/backend/app/logging_config.py @@ -0,0 +1,49 @@ +"""结构化 JSON 日志 + trace_id。""" +import logging +import sys +import uuid +from contextvars import ContextVar + +from pythonjsonlogger import jsonlogger + +trace_id_var: ContextVar[str] = ContextVar("trace_id", default="") + + +def get_trace_id() -> str: + t = trace_id_var.get() + if not t: + t = str(uuid.uuid4()) + trace_id_var.set(t) + return t + + +def set_trace_id(tid: str) -> None: + trace_id_var.set(tid) + + +class TraceIdFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.trace_id = get_trace_id() + return True + + +class JsonFormatter(jsonlogger.JsonFormatter): + def add_fields(self, log_record: dict, record: logging.LogRecord, message_dict: dict) -> None: + super().add_fields(log_record, record, message_dict) + log_record["trace_id"] = getattr(record, "trace_id", "") + log_record["level"] = record.levelname + if record.exc_info: + log_record["exception"] = self.formatException(record.exc_info) + + +def setup_logging(log_level: str = "INFO", log_json: bool = True) -> None: + root = logging.getLogger() + root.handlers.clear() + handler = logging.StreamHandler(sys.stdout) + if log_json: + handler.setFormatter(JsonFormatter("%(timestamp)s %(level)s %(message)s %(trace_id)s")) + else: + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s [%(trace_id)s] %(message)s")) + handler.addFilter(TraceIdFilter()) + root.addHandler(handler) + root.setLevel(getattr(logging, log_level.upper(), logging.INFO)) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..58953a6 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,68 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.routers import auth, wecom +from app.routers.admin import sessions, tickets, kb, settings, users +from app.logging_config import get_trace_id + +app = FastAPI(title="企微AI助手", version="0.1.0") + +# CORS 必须在最前面 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.status_code, "message": str(exc.detail), "data": None, "trace_id": get_trace_id()}, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=422, + content={"code": 422, "message": "参数校验失败", "data": exc.errors(), "trace_id": get_trace_id()}, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + import traceback + traceback.print_exc() + return JSONResponse( + status_code=500, + content={"code": 500, "message": str(exc), "data": None, "trace_id": get_trace_id()}, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(wecom.router, prefix="/api/wecom", tags=["wecom"]) +app.include_router(sessions.router, prefix="/api/admin", tags=["admin"]) +app.include_router(tickets.router, prefix="/api/admin", tags=["admin"]) +app.include_router(kb.router, prefix="/api/admin", tags=["admin"]) +app.include_router(settings.router, prefix="/api/admin", tags=["admin"]) +app.include_router(users.router, prefix="/api/admin", tags=["admin"]) + + +@app.get("/api/health") +def health(): + return {"status": "up", "service": "backend"} + + +@app.get("/api/ready") +def ready(): + return {"ready": True, "service": "backend"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..41cac68 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +from app.models.base import Base +from app.models.user import User +from app.models.audit_log import AuditLog +from app.models.session import ChatSession +from app.models.message import Message + +__all__ = ["Base", "User", "AuditLog", "ChatSession", "Message"] diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..1aa84bd --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,23 @@ +"""审计日志(最简):id、actor_user_id、action、meta_json、created_at。""" +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, Text, ForeignKey +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + actor_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + action: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + meta_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..9455a03 --- /dev/null +++ b/backend/app/models/message.py @@ -0,0 +1,18 @@ +"""单条消息(仅存 public 可见内容,隔离内部信息)。""" +from datetime import datetime +from sqlalchemy import String, Text, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class Message(Base): + __tablename__ = "messages" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False, index=True) + role: Mapped[str] = mapped_column(String(16), nullable=False) # user / assistant / system + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + + session: Mapped["ChatSession"] = relationship("ChatSession", back_populates="messages") diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..1908366 --- /dev/null +++ b/backend/app/models/session.py @@ -0,0 +1,19 @@ +"""外部客户会话(企微单聊/群聊维度)。""" +from datetime import datetime +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class ChatSession(Base): + __tablename__ = "chat_sessions" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + external_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) # 企微 external_userid + external_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(32), default="open") # open / transferred / closed + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) + + messages: Mapped[list["Message"]] = relationship("Message", back_populates="session", order_by="Message.id") diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py new file mode 100644 index 0000000..83359d5 --- /dev/null +++ b/backend/app/models/ticket.py @@ -0,0 +1,17 @@ +"""转人工工单。""" +from datetime import datetime +from sqlalchemy import String, Text, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class Ticket(Base): + __tablename__ = "tickets" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + session_id: Mapped[int] = mapped_column(ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False, index=True) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(32), default="open") # open / handling / closed + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..88be643 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,22 @@ +"""后台用户:id(uuid)、username、password_hash、role、is_active、created_at。""" +import uuid +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(256), nullable=False) + role: Mapped[str] = mapped_column(String(32), nullable=False, default="admin") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..809288b --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,2 @@ +# routers +from app.routers import auth, wecom diff --git a/backend/app/routers/admin/__init__.py b/backend/app/routers/admin/__init__.py new file mode 100644 index 0000000..7bea9d9 --- /dev/null +++ b/backend/app/routers/admin/__init__.py @@ -0,0 +1 @@ +# admin routers diff --git a/backend/app/routers/admin/kb.py b/backend/app/routers/admin/kb.py new file mode 100644 index 0000000..93dc031 --- /dev/null +++ b/backend/app/routers/admin/kb.py @@ -0,0 +1,31 @@ +"""Admin API: GET/POST /api/admin/kb/docs/upload。""" +from fastapi import APIRouter, Depends, UploadFile +from app.deps import get_current_user +from app.logging_config import get_trace_id + +router = APIRouter() + + +@router.get("/kb/docs") +async def list_kb_docs(_user=Depends(get_current_user)): + """知识库文档列表(占位)。""" + return { + "code": 0, + "message": "ok", + "data": [ + {"id": "1", "filename": "faq.pdf", "size": 102400, "uploaded_at": "2025-02-05T10:00:00Z"}, + ], + "trace_id": get_trace_id(), + } + + +@router.post("/kb/docs/upload") +async def upload_kb_doc(file: UploadFile, _user=Depends(get_current_user)): + """上传知识库文档(占位:先存本地/对象存储)。""" + # 占位:实际应保存到对象存储或本地卷 + return { + "code": 0, + "message": "ok", + "data": {"id": "new_1", "filename": file.filename, "size": 0}, + "trace_id": get_trace_id(), + } diff --git a/backend/app/routers/admin/sessions.py b/backend/app/routers/admin/sessions.py new file mode 100644 index 0000000..b344243 --- /dev/null +++ b/backend/app/routers/admin/sessions.py @@ -0,0 +1,40 @@ +"""Admin API: GET /api/admin/sessions, GET /api/admin/sessions/{id}。""" +from fastapi import APIRouter, Depends +from app.deps import get_current_user +from app.logging_config import get_trace_id + +router = APIRouter() + + +@router.get("/sessions") +async def list_sessions(_user=Depends(get_current_user)): + """会话列表(占位)。""" + return { + "code": 0, + "message": "ok", + "data": [ + {"id": "1", "external_user_id": "ext_001", "external_name": "客户A", "status": "open", "created_at": "2025-02-05T10:00:00Z"}, + {"id": "2", "external_user_id": "ext_002", "external_name": "客户B", "status": "transferred", "created_at": "2025-02-05T11:00:00Z"}, + ], + "trace_id": get_trace_id(), + } + + +@router.get("/sessions/{id}") +async def get_session(id: str, _user=Depends(get_current_user)): + """会话详情:消息列表(占位)。""" + return { + "code": 0, + "message": "ok", + "data": { + "id": id, + "external_user_id": "ext_001", + "external_name": "客户A", + "status": "open", + "messages": [ + {"id": 1, "role": "user", "content": "你好", "created_at": "2025-02-05T10:00:00Z"}, + {"id": 2, "role": "assistant", "content": "您好,有什么可以帮您?", "created_at": "2025-02-05T10:00:01Z"}, + ], + }, + "trace_id": get_trace_id(), + } diff --git a/backend/app/routers/admin/settings.py b/backend/app/routers/admin/settings.py new file mode 100644 index 0000000..bd93a47 --- /dev/null +++ b/backend/app/routers/admin/settings.py @@ -0,0 +1,37 @@ +"""Admin API: GET/PATCH /api/admin/settings。""" +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from app.deps import get_current_user +from app.logging_config import get_trace_id + +router = APIRouter() + + +class UpdateSettingsBody(BaseModel): + model_name: str | None = None + strategy: dict | None = None + + +@router.get("/settings") +async def get_settings(_user=Depends(get_current_user)): + """获取设置(占位)。""" + return { + "code": 0, + "message": "ok", + "data": { + "model_name": "gpt-4", + "strategy": {"faq_priority": True, "rag_enabled": False}, + }, + "trace_id": get_trace_id(), + } + + +@router.patch("/settings") +async def update_settings(body: UpdateSettingsBody, _user=Depends(get_current_user)): + """更新设置(占位)。""" + return { + "code": 0, + "message": "ok", + "data": {"model_name": body.model_name or "gpt-4", "strategy": body.strategy or {}}, + "trace_id": get_trace_id(), + } diff --git a/backend/app/routers/admin/tickets.py b/backend/app/routers/admin/tickets.py new file mode 100644 index 0000000..7b508d3 --- /dev/null +++ b/backend/app/routers/admin/tickets.py @@ -0,0 +1,52 @@ +"""Admin API: GET/POST/PATCH /api/admin/tickets。""" +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from app.deps import get_current_user +from app.logging_config import get_trace_id + +router = APIRouter() + + +class CreateTicketBody(BaseModel): + session_id: str + reason: str = "" + + +class UpdateTicketBody(BaseModel): + status: str | None = None + reason: str | None = None + + +@router.get("/tickets") +async def list_tickets(_user=Depends(get_current_user)): + """工单列表(占位)。""" + return { + "code": 0, + "message": "ok", + "data": [ + {"id": "1", "session_id": "1", "reason": "转人工", "status": "open", "created_at": "2025-02-05T10:00:00Z"}, + ], + "trace_id": get_trace_id(), + } + + +@router.post("/tickets") +async def create_ticket(body: CreateTicketBody, _user=Depends(get_current_user)): + """创建工单(占位)。""" + return { + "code": 0, + "message": "ok", + "data": {"id": "new_1", "session_id": body.session_id, "reason": body.reason, "status": "open"}, + "trace_id": get_trace_id(), + } + + +@router.patch("/tickets/{id}") +async def update_ticket(id: str, body: UpdateTicketBody, _user=Depends(get_current_user)): + """更新工单(占位)。""" + return { + "code": 0, + "message": "ok", + "data": {"id": id, "status": body.status or "open", "reason": body.reason}, + "trace_id": get_trace_id(), + } diff --git a/backend/app/routers/admin/users.py b/backend/app/routers/admin/users.py new file mode 100644 index 0000000..fe8acb1 --- /dev/null +++ b/backend/app/routers/admin/users.py @@ -0,0 +1,74 @@ +"""Admin API: GET/POST/PATCH/DELETE /api/admin/users(仅管理员可见)。""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.deps import get_current_user +from app.logging_config import get_trace_id + +router = APIRouter() + + +class CreateUserBody(BaseModel): + username: str + password: str + role: str = "admin" + is_active: bool = True + + +class UpdateUserBody(BaseModel): + password: str | None = None + role: str | None = None + is_active: bool | None = None + + +@router.get("/users") +async def list_users(current_user=Depends(get_current_user)): + """用户列表(仅管理员)。""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="仅管理员可访问") + return { + "code": 0, + "message": "ok", + "data": [ + {"id": "1", "username": "admin", "role": "admin", "is_active": True, "created_at": "2025-02-05T10:00:00Z"}, + ], + "trace_id": get_trace_id(), + } + + +@router.post("/users") +async def create_user(body: CreateUserBody, current_user=Depends(get_current_user)): + """创建用户(仅管理员)。""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="仅管理员可访问") + return { + "code": 0, + "message": "ok", + "data": {"id": "new_1", "username": body.username, "role": body.role, "is_active": body.is_active}, + "trace_id": get_trace_id(), + } + + +@router.patch("/users/{id}") +async def update_user(id: str, body: UpdateUserBody, current_user=Depends(get_current_user)): + """更新用户(仅管理员)。""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="仅管理员可访问") + return { + "code": 0, + "message": "ok", + "data": {"id": id, "role": body.role, "is_active": body.is_active}, + "trace_id": get_trace_id(), + } + + +@router.delete("/users/{id}") +async def delete_user(id: str, current_user=Depends(get_current_user)): + """删除用户(仅管理员)。""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="仅管理员可访问") + return { + "code": 0, + "message": "ok", + "data": None, + "trace_id": get_trace_id(), + } diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..f356750 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,47 @@ +"""Auth API:POST /api/auth/login、GET /api/auth/me。""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.deps import get_current_user +from app.models import User +from app.services.auth_service import ( + get_user_by_username, + verify_password, + create_access_token, +) + +router = APIRouter() + + +class LoginBody(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginBody, db: AsyncSession = Depends(get_db)): + user = await get_user_by_username(db, body.username) + if not user or not verify_password(body.password, user.password_hash): + raise HTTPException(status_code=401, detail="用户名或密码错误") + if not user.is_active: + raise HTTPException(status_code=403, detail="账号已禁用") + token = create_access_token(subject=user.username) + return LoginResponse(access_token=token, token_type="bearer") + + +@router.get("/me") +async def me(current_user: User = Depends(get_current_user)): + return { + "id": str(current_user.id), + "username": current_user.username, + "role": current_user.role, + "is_active": current_user.is_active, + "created_at": current_user.created_at.isoformat() if current_user.created_at else None, + } diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..018be72 --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,15 @@ +"""健康检查:供负载均衡与 CI 验证。""" +from fastapi import APIRouter +from app.logging_config import get_trace_id + +router = APIRouter() + + +@router.get("/health") +def health(): + return { + "code": 0, + "message": "ok", + "data": {"status": "up"}, + "trace_id": get_trace_id(), + } diff --git a/backend/app/routers/knowledge.py b/backend/app/routers/knowledge.py new file mode 100644 index 0000000..7af4147 --- /dev/null +++ b/backend/app/routers/knowledge.py @@ -0,0 +1,12 @@ +"""知识库上传占位:先落本地卷/对象存储占位。""" +from fastapi import APIRouter, UploadFile +from app.logging_config import get_trace_id + +router = APIRouter() + + +@router.post("/upload") +async def upload_file(file: UploadFile): + """上传知识库文件,占位落盘。""" + # 占位:保存到 backend/uploads 或配置的存储 + return {"code": 0, "message": "ok", "data": {"filename": file.filename}, "trace_id": get_trace_id()} diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py new file mode 100644 index 0000000..e024703 --- /dev/null +++ b/backend/app/routers/sessions.py @@ -0,0 +1,53 @@ +"""会话列表与消息:从 DB 读取,需登录。""" +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.deps import get_current_user_id +from app.logging_config import get_trace_id +from app.models import ChatSession, Message + +router = APIRouter() + + +def _session_row(s: ChatSession) -> dict: + return { + "id": s.id, + "external_user_id": s.external_user_id, + "external_name": s.external_name, + "status": s.status, + "created_at": s.created_at.isoformat() if s.created_at else None, + } + + +def _message_row(m: Message) -> dict: + return { + "id": m.id, + "role": m.role, + "content": m.content, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + + +@router.get("") +async def list_sessions( + db: AsyncSession = Depends(get_db), + _user: str = Depends(get_current_user_id), +): + """会话列表。""" + r = await db.execute(select(ChatSession).order_by(ChatSession.updated_at.desc())) + rows = r.scalars().all() + return {"code": 0, "message": "ok", "data": [_session_row(s) for s in rows], "trace_id": get_trace_id()} + + +@router.get("/{session_id}/messages") +async def list_messages( + session_id: int, + db: AsyncSession = Depends(get_db), + _user: str = Depends(get_current_user_id), +): + """某会话消息列表。""" + r = await db.execute(select(Message).where(Message.session_id == session_id).order_by(Message.id)) + rows = r.scalars().all() + return {"code": 0, "message": "ok", "data": [_message_row(m) for m in rows], "trace_id": get_trace_id()} diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..7d8a512 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,15 @@ +"""设置页占位:仅占位接口。""" +from fastapi import APIRouter +from app.logging_config import get_trace_id + +router = APIRouter() + + +@router.get("") +def get_settings(): + return {"code": 0, "message": "ok", "data": {}, "trace_id": get_trace_id()} + + +@router.put("") +def update_settings(): + return {"code": 0, "message": "ok", "data": None, "trace_id": get_trace_id()} diff --git a/backend/app/routers/tickets.py b/backend/app/routers/tickets.py new file mode 100644 index 0000000..3b36bf9 --- /dev/null +++ b/backend/app/routers/tickets.py @@ -0,0 +1,67 @@ +"""工单转人工:创建工单入库、手动回复调企业微信 API。""" +import logging +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.deps import get_current_user_id +from app.logging_config import get_trace_id +from app.models import ChatSession, Ticket +from app.services.wecom_api import send_text_to_external + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class CreateTicketBody(BaseModel): + session_id: int + reason: str = "" + + +class SendReplyBody(BaseModel): + session_id: int + content: str + + +@router.post("") +async def create_ticket( + body: CreateTicketBody, + db: AsyncSession = Depends(get_db), + _user: str = Depends(get_current_user_id), +): + """创建转人工工单并更新会话状态。""" + r = await db.execute(select(ChatSession).where(ChatSession.id == body.session_id)) + session = r.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + ticket = Ticket(session_id=body.session_id, reason=body.reason or None) + db.add(ticket) + session.status = "transferred" + await db.flush() + return { + "code": 0, + "message": "ok", + "data": {"ticket_id": str(ticket.id)}, + "trace_id": get_trace_id(), + } + + +@router.post("/reply") +async def send_reply( + body: SendReplyBody, + db: AsyncSession = Depends(get_db), + _user: str = Depends(get_current_user_id), +): + """手动回复:通过企业微信 API 发给客户。""" + r = await db.execute(select(ChatSession).where(ChatSession.id == body.session_id)) + session = r.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + try: + await send_text_to_external(session.external_user_id, body.content) + except Exception as e: + logger.exception("wecom send reply failed") + raise HTTPException(status_code=502, detail=str(e)) + return {"code": 0, "message": "ok", "data": None, "trace_id": get_trace_id()} diff --git a/backend/app/routers/wecom.py b/backend/app/routers/wecom.py new file mode 100644 index 0000000..476c948 --- /dev/null +++ b/backend/app/routers/wecom.py @@ -0,0 +1,163 @@ +"""企业微信回调:GET 校验 + POST 消息回调(验签、解密、echo 回复、会话入库)。""" +import time +import logging +import random +import string +from fastapi import APIRouter, Request, Query, Depends +from fastapi.responses import PlainTextResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.services.session_service import get_or_create_session, add_message +from app.services.wecom_crypto import ( + verify_and_decrypt_echostr, + verify_signature, + parse_encrypted_body, + decrypt, + parse_decrypted_xml, + build_reply_xml, + encrypt, + make_reply_signature, + build_encrypted_response, +) +from app.logging_config import get_trace_id + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/callback") +def wecom_verify( + request: Request, + signature: str = Query(None, alias="signature"), + msg_signature: str = Query(None, alias="msg_signature"), + timestamp: str = Query(..., alias="timestamp"), + nonce: str = Query(..., alias="nonce"), + echostr: str = Query(..., alias="echostr"), +): + """企业微信 GET 验签:校验签名并解密 echostr,原样返回明文。 + 兼容 signature 和 msg_signature 两种参数名。 + """ + trace_id = get_trace_id() + # 兼容 signature 和 msg_signature 两种参数名 + sig = msg_signature or signature + if not sig: + logger.warning( + "wecom verify missing signature", + extra={"trace_id": trace_id, "query_params": dict(request.query_params)}, + ) + return PlainTextResponse("", status_code=400) + plain = verify_and_decrypt_echostr(sig, timestamp, nonce, echostr) + if plain is None: + logger.warning( + "wecom verify failed", + extra={"trace_id": trace_id, "timestamp": timestamp, "nonce": nonce}, + ) + return PlainTextResponse("", status_code=400) + logger.info( + "wecom verify success", + extra={"trace_id": trace_id, "echostr_length": len(echostr)}, + ) + return PlainTextResponse(plain) + + +@router.post("/callback") +async def wecom_callback( + request: Request, + signature: str = Query(None, alias="signature"), + msg_signature: str = Query(None, alias="msg_signature"), + timestamp: str = Query(..., alias="timestamp"), + nonce: str = Query(..., alias="nonce"), + db: AsyncSession = Depends(get_db), +): + """POST 消息回调:验签、解密、会话与消息入库、文本 echo 回复。 + 兼容 signature 和 msg_signature 两种参数名。 + """ + trace_id = get_trace_id() + # 兼容 signature 和 msg_signature 两种参数名 + sig = msg_signature or signature + if not sig: + logger.warning( + "wecom post missing signature", + extra={"trace_id": trace_id, "query_params": dict(request.query_params)}, + ) + return PlainTextResponse("", status_code=400) + body = await request.body() + encrypt_raw, err = parse_encrypted_body(body) + if err: + logger.warning( + "wecom post parse error", + extra={"trace_id": trace_id, "error": err}, + ) + return PlainTextResponse("", status_code=400) + if not verify_signature(sig, timestamp, nonce, encrypt_raw): + logger.warning( + "wecom post verify failed", + extra={"trace_id": trace_id, "timestamp": timestamp}, + ) + return PlainTextResponse("", status_code=400) + try: + plain_xml = decrypt(encrypt_raw) + except Exception as e: + logger.warning( + "wecom decrypt error", + extra={"trace_id": trace_id, "error": str(e)}, + ) + return PlainTextResponse("", status_code=400) + msg = parse_decrypted_xml(plain_xml) + if not msg: + logger.warning( + "wecom xml parse failed", + extra={"trace_id": trace_id}, + ) + return PlainTextResponse("", status_code=400) + to_user = msg.get("ToUserName", "") + from_user = msg.get("FromUserName", "") # external_userid + msg_id = msg.get("MsgId", "") + msg_type = msg.get("MsgType", "") + content = (msg.get("Content") or "").strip() + content_summary = content[:50] + "..." if len(content) > 50 else content + + # 记录日志:trace_id + external_userid + msgid + 内容摘要 + logger.info( + "wecom message received", + extra={ + "trace_id": trace_id, + "external_userid": from_user, + "msgid": msg_id, + "msg_type": msg_type, + "content_summary": content_summary or "(empty)", + }, + ) + + # 会话入库:external_user_id = from_user(客户) + session = await get_or_create_session(db, from_user, msg.get("Contact")) + await add_message(db, session.id, "user", content or "(非文本消息)") + + # Echo 文本:回复"已收到:{用户消息}" + if msg_type == "text" and content: + reply_content = f"已收到:{content}" + else: + reply_content = "已收到" + + await add_message(db, session.id, "assistant", reply_content) + + # 回复给客户(被动回复 XML) + reply_xml = build_reply_xml(from_user, to_user, reply_content) + enc = encrypt(reply_xml) + ts = str(int(time.time())) + reply_nonce = "".join(random.choices(string.ascii_letters + string.digits, k=16)) + sig = make_reply_signature(enc, ts, reply_nonce) + resp_xml = build_encrypted_response(enc, sig, ts, reply_nonce) + + logger.info( + "wecom reply sent", + extra={ + "trace_id": trace_id, + "external_userid": from_user, + "msgid": msg_id, + "reply_summary": reply_content[:50] + "..." if len(reply_content) > 50 else reply_content, + }, + ) + + return PlainTextResponse(resp_xml, media_type="application/xml") diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..a0b12d7 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# services diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..1e94928 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,39 @@ +"""密码 bcrypt hash;JWT 创建与解码,带过期时间。""" +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(subject: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + to_encode = {"sub": subject, "exp": expire} + return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> str | None: + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + return payload.get("sub") + except JWTError: + return None + + +async def get_user_by_username(db: AsyncSession, username: str) -> User | None: + r = await db.execute(select(User).where(User.username == username)) + return r.scalar_one_or_none() diff --git a/backend/app/services/session_service.py b/backend/app/services/session_service.py new file mode 100644 index 0000000..afe9154 --- /dev/null +++ b/backend/app/services/session_service.py @@ -0,0 +1,30 @@ +"""会话与消息入库;仅存 public 可见内容,隔离内部信息。""" +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import ChatSession, Message + + +async def get_or_create_session( + db: AsyncSession, + external_user_id: str, + external_name: str | None = None, +) -> ChatSession: + r = await db.execute(select(ChatSession).where(ChatSession.external_user_id == external_user_id)) + row = r.scalar_one_or_none() + if row: + if external_name is not None and row.external_name != external_name: + row.external_name = external_name + await db.flush() + return row + session = ChatSession(external_user_id=external_user_id, external_name=external_name or None) + db.add(session) + await db.flush() + return session + + +async def add_message(db: AsyncSession, session_id: int, role: str, content: str) -> Message: + msg = Message(session_id=session_id, role=role, content=content) + db.add(msg) + await db.flush() + return msg diff --git a/backend/app/services/wecom_api.py b/backend/app/services/wecom_api.py new file mode 100644 index 0000000..0fb2480 --- /dev/null +++ b/backend/app/services/wecom_api.py @@ -0,0 +1,54 @@ +"""企业微信 API 调用:超时与重试,配置来自环境变量。""" +import logging +from typing import Any + +import httpx + +from app.config import settings + +logger = logging.getLogger(__name__) +TIMEOUT = settings.wecom_api_timeout +RETRIES = settings.wecom_api_retries +BASE = settings.wecom_api_base.rstrip("/") + + +async def _request(method: str, path: str, **kwargs: Any) -> dict | None: + url = f"{BASE}{path}" + for attempt in range(RETRIES + 1): + try: + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + r = await client.request(method, url, **kwargs) + r.raise_for_status() + return r.json() + except Exception as e: + logger.warning("wecom api attempt %s failed: %s", attempt + 1, e) + if attempt == RETRIES: + raise + return None + + +async def get_access_token() -> str: + """获取 corpid + secret 的 access_token。""" + r = await _request( + "GET", + "/cgi-bin/gettoken", + params={"corpid": settings.wecom_corp_id, "corpsecret": settings.wecom_secret}, + ) + if not r or r.get("errcode") != 0: + raise RuntimeError(r.get("errmsg", "get token failed")) + return r["access_token"] + + +async def send_text_to_external(external_user_id: str, content: str) -> None: + """发送文本消息给外部联系人(客户联系-发送消息到客户)。""" + token = await get_access_token() + body = { + "touser": [external_user_id], + "sender": settings.wecom_agent_id, + "msgtype": "text", + "text": {"content": content}, + } + # 企业微信文档:发送消息到客户 send_message_to_user + r = await _request("POST", f"/cgi-bin/externalcontact/send_message_to_user?access_token={token}", json=body) + if not r or r.get("errcode") != 0: + raise RuntimeError(r.get("errmsg", "send failed")) \ No newline at end of file diff --git a/backend/app/services/wecom_crypto.py b/backend/app/services/wecom_crypto.py new file mode 100644 index 0000000..00b0bd5 --- /dev/null +++ b/backend/app/services/wecom_crypto.py @@ -0,0 +1,119 @@ +"""企业微信回调加解密与验签(与企微文档一致)。""" +import base64 +import hashlib +import struct +import xml.etree.ElementTree as ET +from typing import Tuple + +from Crypto.Cipher import AES + +from app.config import settings + + +def _sha1(s: str) -> str: + return hashlib.sha1(s.encode()).hexdigest() + + +def _check_signature(signature: str, timestamp: str, nonce: str, echostr_or_encrypt: str) -> bool: + token = settings.wecom_token + lst = [token, timestamp, nonce, echostr_or_encrypt] + lst.sort() + return _sha1("".join(lst)) == signature + + +def _aes_key() -> bytes: + key_b64 = settings.wecom_encoding_aes_key + "=" + return base64.b64decode(key_b64)[:32] + + +def decrypt(encrypt: str) -> str: + """解密企微回调密文(echostr 或 Encrypt 节点内容)。""" + key = _aes_key() + iv = key[:16] + raw = base64.b64decode(encrypt) + cipher = AES.new(key, AES.MODE_CBC, iv) + dec = cipher.decrypt(raw) + # 16 随机字节 + 4 字节长度(big-endian) + 消息 + corpid;先按长度取消息,避免 padding 差异 + msg_len = struct.unpack(">I", dec[16:20])[0] + return dec[20 : 20 + msg_len].decode("utf-8") + + +def encrypt(plain: str) -> str: + """加密回复内容(明文为 XML 或文本)。""" + import os + key = _aes_key() + iv = key[:16] + corpid = settings.wecom_corp_id or "placeholder" + msg = plain.encode("utf-8") + msg_len = struct.pack(">I", len(msg)) + rand = os.urandom(16) + to_enc = rand + msg_len + msg + corpid.encode("utf-8") + from Crypto.Util.Padding import pad + to_enc = pad(to_enc, 16) + cipher = AES.new(key, AES.MODE_CBC, iv) + enc = cipher.encrypt(to_enc) + return base64.b64encode(enc).decode("ascii") + + +def verify_signature(msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bool: + """校验签名(GET 或 POST 的 Encrypt)。""" + return _check_signature(msg_signature, timestamp, nonce, encrypt) + + +def verify_and_decrypt_echostr(msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str | None: + """GET 校验:验签并解密 echostr,返回明文;失败返回 None。""" + if not verify_signature(msg_signature, timestamp, nonce, echostr): + return None + return decrypt(echostr) + + +def parse_encrypted_body(body: bytes) -> Tuple[str | None, str | None]: + """解析 POST 请求体 XML,取 Encrypt;验签用 msg_signature/timestamp/nonce 从 query 传。返回 (encrypt_raw, None) 或 (None, error)。""" + try: + root = ET.fromstring(body) + encrypt_el = root.find("Encrypt") + if encrypt_el is None or encrypt_el.text is None: + return None, "missing Encrypt" + return encrypt_el.text.strip(), None + except Exception as e: + return None, str(e) + + +def parse_decrypted_xml(plain_xml: str) -> dict | None: + """解密后的 XML 解析为 dict(ToUserName, FromUserName, MsgType, Content 等)。""" + try: + root = ET.fromstring(plain_xml) + d = {} + for c in root: + if c.text: + d[c.tag] = c.text + return d + except Exception: + return None + + +def build_reply_xml(to_user: str, from_user: str, content: str) -> str: + """构造文本回复 XML(明文)。""" + return f""" + + +{int(__import__("time").time())} + + +""" + + +def make_reply_signature(encrypt: str, timestamp: str, nonce: str) -> str: + lst = [settings.wecom_token, timestamp, nonce, encrypt] + lst.sort() + return _sha1("".join(lst)) + + +def build_encrypted_response(encrypt: str, signature: str, timestamp: str, nonce: str) -> str: + """构造 POST 回复的加密 XML。""" + return f""" + + +{timestamp} + +""" diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..0dcd925 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,26 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + api_host: str = "0.0.0.0" + api_port: int = 8000 + database_url: str = "postgresql+asyncpg://wecom:wecom_secret@localhost:5432/wecom_ai" + database_url_sync: str = "postgresql://wecom:wecom_secret@localhost:5432/wecom_ai" + jwt_secret: str = "change-me" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 60 + wecom_corp_id: str = "" + wecom_agent_id: str = "" + wecom_secret: str = "" + wecom_token: str = "" + wecom_encoding_aes_key: str = "" + wecom_api_base: str = "https://qyapi.weixin.qq.com" + wecom_api_timeout: int = 10 + wecom_api_retries: int = 2 + log_level: str = "INFO" + log_json: bool = True + + +settings = Settings() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..9bd3e0e --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,4 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +pythonpath = ["."] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f1a9d76 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,22 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +pydantic-settings==2.6.1 +python-multipart==0.0.9 +python-dotenv==1.0.1 + +# DB +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.0 +psycopg2-binary==2.9.10 + +# Auth +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.1.2 + +# Logging +python-json-logger==2.0.7 + +# WeCom Crypto +pycryptodome==3.21.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..8baff0c --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# tests diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..4baa947 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,26 @@ +"""登录接口测试。""" +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_login_fail_wrong_password(): + r = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}) + assert r.status_code == 401 + + +def test_login_fail_wrong_user(): + r = client.post("/api/auth/login", json={"username": "nobody", "password": "admin"}) + assert r.status_code == 401 + + +def test_login_returns_json(): + """无 DB 时可能 401;有 DB 且 admin 存在时 200。仅断言响应为 JSON 且含 code。""" + r = client.post("/api/auth/login", json={"username": "admin", "password": "admin"}) + assert r.headers.get("content-type", "").startswith("application/json") + data = r.json() + assert "code" in data + assert "trace_id" in data diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..db2c465 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,16 @@ +"""Health 接口测试。""" +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health(): + r = client.get("/api/health") + assert r.status_code == 200 + data = r.json() + assert data.get("code") == 0 + assert data.get("data", {}).get("status") == "up" + assert "trace_id" in data diff --git a/backend/tests/test_wecom.py b/backend/tests/test_wecom.py new file mode 100644 index 0000000..8c36830 --- /dev/null +++ b/backend/tests/test_wecom.py @@ -0,0 +1,26 @@ +"""企业微信回调验签逻辑测试(不依赖真实 Token/Key)。""" +import pytest +from unittest.mock import patch + +from app.services.wecom_crypto import ( + verify_signature, + verify_and_decrypt_echostr, + _sha1, +) + + +def test_sha1(): + h = _sha1("abc") + assert len(h) == 40 + assert h == "a9993e364706816aba3e25717850c26c9cd0d89d" + + +def test_verify_signature(): + # 用固定 token 时,签名为 sha1(sort(token, ts, nonce, encrypt)) + with patch("app.services.wecom_crypto.settings") as s: + s.wecom_token = "mytoken" + lst = ["mytoken", "123", "456", "echostr"] + lst.sort() + expected = _sha1("".join(lst)) + assert verify_signature(expected, "123", "456", "echostr") is True + assert verify_signature("wrong", "123", "456", "echostr") is False diff --git a/deploy/ci/github-actions.yml b/deploy/ci/github-actions.yml new file mode 100644 index 0000000..18373b3 --- /dev/null +++ b/deploy/ci/github-actions.yml @@ -0,0 +1,95 @@ +# 与 .github/workflows/build-deploy.yml 保持一致,供参考或复制到 .github/workflows/ +# 推送 main 时:测试后端 → 构建并推送 backend/admin 镜像到 ghcr.io +name: Build and Deploy + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + +jobs: + build-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push backend + uses: docker/build-push-action@v6 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-backend:latest + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-backend:${{ github.sha }} + + build-admin: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push admin + uses: docker/build-push-action@v6 + with: + context: ./admin + file: ./admin/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-admin:latest + ${{ env.REGISTRY }}/${{ github.repository_owner }}/wecom-ai-admin:${{ github.sha }} + + test-backend: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: wecom + POSTGRES_PASSWORD: wecom_secret + POSTGRES_DB: wecom_ai + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql+asyncpg://wecom:wecom_secret@localhost:5432/wecom_ai + DATABASE_URL_SYNC: postgresql://wecom:wecom_secret@localhost:5432/wecom_ai + JWT_SECRET: test-secret + WECOM_TOKEN: test + WECOM_ENCODING_AES_KEY: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + cd backend + pip install -r requirements.txt + - name: Run migrations + run: | + cd backend + alembic upgrade head + - name: Pytest + run: | + cd backend + pytest tests/ -v --tb=short diff --git a/deploy/docker/admin.Dockerfile b/deploy/docker/admin.Dockerfile new file mode 100644 index 0000000..28971b7 --- /dev/null +++ b/deploy/docker/admin.Dockerfile @@ -0,0 +1,52 @@ +# 生产环境 Admin Dockerfile(最小构建) +# 用途:构建优化的 Next.js 生产镜像 +FROM node:20-alpine AS builder + +WORKDIR /app + +# 设置 npm 镜像(可选,根据网络情况) +ARG NPM_REGISTRY=https://registry.npmjs.org +RUN npm config set registry ${NPM_REGISTRY} + +# 复制依赖文件 +COPY admin/package.json admin/package-lock.json* ./ + +# 安装依赖 +RUN npm ci --legacy-peer-deps --only=production + +# 复制源代码 +COPY admin/ . + +# 构建 Next.js 应用 +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# 生产镜像 +FROM node:20-alpine + +WORKDIR /app + +# 从 builder 复制构建产物 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +# 设置环境变量 +ENV NODE_ENV=production +ENV PORT=3000 +ENV NEXT_TELEMETRY_DISABLED=1 + +# 创建非 root 用户 +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nextjs -u 1001 && \ + chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1 + +CMD ["node", "server.js"] diff --git a/deploy/docker/backend.Dockerfile b/deploy/docker/backend.Dockerfile new file mode 100644 index 0000000..aef144b --- /dev/null +++ b/deploy/docker/backend.Dockerfile @@ -0,0 +1,46 @@ +# 生产环境 Backend Dockerfile +# 用途:构建优化的生产镜像 +FROM python:3.12-slim AS builder + +WORKDIR /app + +# 安装构建依赖 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY backend/requirements.txt . + +# 安装 Python 依赖 +RUN pip install --no-cache-dir --user -r requirements.txt + +# 生产镜像 +FROM python:3.12-slim + +WORKDIR /app + +# 从 builder 复制已安装的依赖 +COPY --from=builder /root/.local /root/.local + +# 复制应用代码 +COPY backend/ . + +# 设置环境变量 +ENV PYTHONPATH=/app +ENV PATH=/root/.local/bin:$PATH + +# 创建非 root 用户(安全最佳实践) +RUN useradd -m -u 1000 appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 + +# 启动命令:先执行数据库迁移,再启动服务 +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"] diff --git a/deploy/docker/nginx.conf b/deploy/docker/nginx.conf new file mode 100644 index 0000000..6e3684e --- /dev/null +++ b/deploy/docker/nginx.conf @@ -0,0 +1,138 @@ +# 生产环境 Nginx 配置 +# 用途:反向代理 + HTTPS 支持 + +events { + worker_connections 1024; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # 性能优化 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 20M; + + # Gzip 压缩 + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss; + + # Upstream 定义 + upstream backend { + server backend:8000; + keepalive 32; + } + + upstream admin { + server admin:3000; + keepalive 32; + } + + # HTTP → HTTPS 重定向 + server { + listen 80; + server_name _; + + # Let's Encrypt 验证路径(用于证书申请和续期) + location /.well-known/acme-challenge/ { + root /var/www/certbot; + try_files $uri =404; + } + + # 健康检查(允许 HTTP 访问) + location /health { + proxy_pass http://backend/api/health; + access_log off; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$host$request_uri; + } + } + + # HTTPS 服务器 + server { + listen 443 ssl http2; + server_name _; + + # SSL 证书配置(Let's Encrypt) + # 注意:首次部署时,这些路径可能不存在,需要先配置证书 + # 证书配置步骤见 docs/deploy.md + ssl_certificate /etc/letsencrypt/live/_/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/_/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # HSTS(可选,生产环境推荐) + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # /api -> backend + location /api/ { + proxy_pass http://backend; + 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_read_timeout 30s; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + } + + # 健康检查 + location /health { + proxy_pass http://backend/api/health; + access_log off; + } + + # 其余 -> admin(如果 admin 未上线,返回静态占位页) + location / { + # 如果 admin 服务不可用,返回占位页 + # 可以通过检查 admin 服务状态来决定 + proxy_pass http://admin; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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_read_timeout 30s; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + + # 如果 admin 服务不可用,返回 503 或静态占位页 + # 可以通过 error_page 配置实现 + error_page 502 503 504 = @admin_fallback; + } + + # Admin 服务不可用时的占位页 + location @admin_fallback { + default_type text/html; + return 503 'Admin 服务维护中

Admin 服务维护中

管理后台暂时不可用,请稍后再试。

'; + } + } +} diff --git a/deploy/nginx-ssl.conf b/deploy/nginx-ssl.conf new file mode 100644 index 0000000..626265d --- /dev/null +++ b/deploy/nginx-ssl.conf @@ -0,0 +1,59 @@ +events { worker_connections 1024; } + +http { + upstream backend { + server backend:8000; + } + + # HTTP → HTTPS 重定向 + server { + listen 80; + server_name _; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$host$request_uri; + } + } + + # HTTPS + server { + listen 443 ssl http2; + server_name _; + + # SSL 证书(Let's Encrypt) + # 注意:在生产环境中,需要将证书路径挂载到容器中 + # ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + # 临时自签名证书(仅用于测试,生产环境必须使用 Let's Encrypt) + # ssl_certificate /etc/nginx/ssl/cert.pem; + # ssl_certificate_key /etc/nginx/ssl/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # /api -> backend + location /api/ { + proxy_pass http://backend; + 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_read_timeout 30s; + proxy_connect_timeout 10s; + } + + # 健康检查 + location /health { + proxy_pass http://backend/health; + access_log off; + } + } +} diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..21d66b9 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,36 @@ +events { worker_connections 1024; } + +http { + upstream backend { + server backend:8000; + } + upstream admin { + server admin:3000; + } + + server { + listen 80; + server_name _; + + # /api -> backend + location /api/ { + proxy_pass http://backend; + 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; + } + + # 其余 -> admin + location / { + proxy_pass http://admin; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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; + } + } +} diff --git a/deploy/scripts/acceptance.sh b/deploy/scripts/acceptance.sh new file mode 100644 index 0000000..5f6bcaf --- /dev/null +++ b/deploy/scripts/acceptance.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# 最小闭环验收脚本:健康检查、登录、可选回调验签 +# 用法: BASE_URL=http://localhost ./acceptance.sh 或 BASE_URL=https://your-domain.com ./acceptance.sh + +set -e +BASE_URL="${BASE_URL:-http://localhost}" + +echo "=== 1. Health ===" +r=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/health") +if [ "$r" != "200" ]; then + echo "FAIL health: got $r" + exit 1 +fi +echo "OK" + +echo "=== 2. Login ===" +login=$(curl -s -X POST "$BASE_URL/api/auth/login" -H "Content-Type: application/json" -d '{"username":"admin","password":"admin"}') +code=$(echo "$login" | grep -o '"code":[0-9]*' | cut -d: -f2) +if [ "$code" != "0" ]; then + echo "FAIL login: $login" + exit 1 +fi +echo "OK" + +echo "=== 3. WeCom callback GET (验签需正确 Token/Key,此处仅检查 200 或 400) ===" +wecom_get=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/wecom/callback?msg_signature=xxx×tamp=1&nonce=1&echostr=xxx") +if [ "$wecom_get" != "200" ] && [ "$wecom_get" != "400" ]; then + echo "WARN wecom GET: got $wecom_get (expected 200 or 400)" +fi +echo "OK (status $wecom_get)" + +echo "=== All checks passed ===" diff --git a/deploy/scripts/deploy-minimal.sh b/deploy/scripts/deploy-minimal.sh new file mode 100644 index 0000000..fd5a327 --- /dev/null +++ b/deploy/scripts/deploy-minimal.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# 云端最小回调壳部署脚本 +# 用途:在备案域名服务器上部署最小可用回调壳 + +set -e + +echo "=== 企业微信 AI 助手 - 最小回调壳部署 ===" +echo "" + +# 检查环境变量 +if [ -z "$DOMAIN" ]; then + echo "错误: 未设置 DOMAIN 环境变量" + echo "请设置: export DOMAIN=your-domain.com" + exit 1 +fi + +if [ ! -f ".env" ]; then + echo "错误: 未找到 .env 文件" + echo "请复制 .env.example 并填写必需变量" + exit 1 +fi + +# 检查必需的环境变量 +source .env +required_vars=("WECOM_TOKEN" "WECOM_ENCODING_AES_KEY" "WECOM_CORP_ID" "WECOM_AGENT_ID") +for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo "错误: 未设置 $var 环境变量" + exit 1 + fi +done + +echo "[1/5] 检查 Docker 环境..." +if ! command -v docker &> /dev/null; then + echo "错误: Docker 未安装" + exit 1 +fi + +if ! command -v docker-compose &> /dev/null; then + echo "错误: docker-compose 未安装" + exit 1 +fi + +echo "✓ Docker 环境正常" +echo "" + +echo "[2/5] 构建后端镜像..." +docker-compose build backend +echo "✓ 构建完成" +echo "" + +echo "[3/5] 启动服务(最小回调壳:backend + nginx)..." +docker-compose up -d backend nginx +echo "✓ 服务已启动" +echo "" + +echo "[4/5] 等待服务就绪..." +sleep 5 + +# 检查健康检查 +max_retries=30 +retry_count=0 +while [ $retry_count -lt $max_retries ]; do + if curl -f -s http://localhost:8000/health > /dev/null 2>&1; then + echo "✓ 后端服务健康检查通过" + break + fi + retry_count=$((retry_count + 1)) + echo "等待后端服务启动... ($retry_count/$max_retries)" + sleep 2 +done + +if [ $retry_count -eq $max_retries ]; then + echo "警告: 后端服务健康检查超时" + echo "请检查日志: docker-compose logs backend" +fi +echo "" + +echo "[5/5] 部署完成!" +echo "" +echo "=== 下一步 ===" +echo "1. 配置企业微信回调 URL: https://$DOMAIN/api/wecom/callback" +echo "2. Token: $WECOM_TOKEN" +echo "3. EncodingAESKey: $WECOM_ENCODING_AES_KEY" +echo "" +echo "查看日志: docker-compose logs -f backend" +echo "检查服务: docker-compose ps" +echo "" diff --git a/deploy/scripts/fix_alembic_version.py b/deploy/scripts/fix_alembic_version.py new file mode 100644 index 0000000..f95ac0f --- /dev/null +++ b/deploy/scripts/fix_alembic_version.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +修复 alembic_version 表:如果数据库里记录了不存在的版本号(如 002), +将其重置为当前最新的迁移版本(如 001)。 +用法(在项目根目录): + python deploy/scripts/fix_alembic_version.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "backend")) + +from dotenv import load_dotenv + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env")) + +from sqlalchemy import create_engine, text + +DATABASE_URL_SYNC = os.getenv( + "DATABASE_URL_SYNC", + "postgresql://wecom:wecom_secret@localhost:5432/wecom_ai", +) + + +def main(): + engine = create_engine(DATABASE_URL_SYNC) + with engine.connect() as conn: + # 检查 alembic_version 表 + try: + r = conn.execute(text("SELECT version_num FROM alembic_version")) + current = r.scalar_one_or_none() + if current: + print(f"当前数据库版本: {current}") + if current == "002": + print("检测到版本 002,但本地只有 001。重置为 001...") + conn.execute(text("UPDATE alembic_version SET version_num = '001'")) + conn.commit() + print("已重置为 001。") + else: + print(f"版本 {current} 正常,无需修复。") + else: + print("alembic_version 表为空,无需修复。") + except Exception as e: + if "does not exist" in str(e) or "relation" in str(e).lower(): + print("alembic_version 表不存在,这是首次迁移前的状态,正常。") + else: + print(f"错误: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/deploy/scripts/fix_users_table.py b/deploy/scripts/fix_users_table.py new file mode 100644 index 0000000..994d6e5 --- /dev/null +++ b/deploy/scripts/fix_users_table.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +修复 users 表:添加缺失的列(role、is_active、created_at)。 +用法(在项目根目录): + python deploy/scripts/fix_users_table.py +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "backend")) + +from dotenv import load_dotenv + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env")) + +from sqlalchemy import create_engine, text, inspect + +DATABASE_URL_SYNC = os.getenv( + "DATABASE_URL_SYNC", + "postgresql://wecom:wecom_secret@localhost:5432/wecom_ai", +) + + +def main(): + engine = create_engine(DATABASE_URL_SYNC) + inspector = inspect(engine) + + if not inspector.has_table("users"): + print("users 表不存在,请先执行迁移。") + sys.exit(1) + + columns = [c["name"] for c in inspector.get_columns("users")] + print(f"当前 users 表的列: {columns}") + + with engine.connect() as conn: + if "role" not in columns: + print("添加 role 列...") + conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(32) NOT NULL DEFAULT 'admin'")) + conn.commit() + print("✓ role 已添加") + + if "is_active" not in columns: + print("添加 is_active 列...") + conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true")) + conn.commit() + print("✓ is_active 已添加") + + if "created_at" not in columns: + print("添加 created_at 列...") + conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()")) + conn.commit() + print("✓ created_at 已添加") + + print("修复完成。") + + +if __name__ == "__main__": + main() diff --git a/deploy/scripts/migrate.ps1 b/deploy/scripts/migrate.ps1 new file mode 100644 index 0000000..91fbc7e --- /dev/null +++ b/deploy/scripts/migrate.ps1 @@ -0,0 +1,31 @@ +# One-click migrate (Docker). Run from project root: .\deploy\scripts\migrate.ps1 + +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +Set-Location $ProjectRoot + +Write-Host "[1/3] Starting database..." -ForegroundColor Cyan +docker compose up -d db + +Write-Host "[2/3] Waiting for DB ready..." -ForegroundColor Cyan +$max = 30 +for ($i = 0; $i -lt $max; $i++) { + $null = docker compose exec -T db pg_isready -U wecom -d wecom_ai 2>$null + if ($LASTEXITCODE -eq 0) { break } + Start-Sleep -Seconds 1 +} +if ($i -ge $max) { + Write-Host "DB not ready in ${max}s. Check: docker compose logs db" -ForegroundColor Red + exit 1 +} + +Write-Host "[3/3] Running Alembic upgrade head..." -ForegroundColor Cyan +docker compose run --rm backend sh -c "alembic upgrade head" +$code = $LASTEXITCODE + +if ($code -eq 0) { + Write-Host "Migrate done." -ForegroundColor Green +} else { + Write-Host "Migrate failed. See errors above." -ForegroundColor Red + exit 1 +} diff --git a/deploy/scripts/migrate.py b/deploy/scripts/migrate.py new file mode 100644 index 0000000..0ad763a --- /dev/null +++ b/deploy/scripts/migrate.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +一键迁移:支持 Docker 或本机执行,需在项目根目录运行。 + python deploy/scripts/migrate.py # 默认用 Docker + python deploy/scripts/migrate.py --local # 本机执行(需已安装依赖且数据库可连) +""" +import os +import subprocess +import sys +import time + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +BACKEND_DIR = os.path.join(PROJECT_ROOT, "backend") + + +def run(cmd, cwd=None, env=None): + r = subprocess.run(cmd, cwd=cwd or PROJECT_ROOT, env=env or os.environ.copy(), shell=True) + if r.returncode != 0: + sys.exit(r.returncode) + + +def migrate_docker(): + os.chdir(PROJECT_ROOT) + print("[1/3] 启动数据库...") + run("docker compose up -d db") + print("[2/3] 等待数据库就绪...") + for i in range(30): + r = subprocess.run( + "docker compose exec -T db pg_isready -U wecom -d wecom_ai", + shell=True, + cwd=PROJECT_ROOT, + capture_output=True, + ) + if r.returncode == 0: + break + time.sleep(1) + else: + print("数据库未在 30s 内就绪,请检查 docker compose logs db") + sys.exit(1) + print("[3/3] 执行 Alembic 迁移...") + run('docker compose run --rm backend sh -c "alembic upgrade head"') + print("迁移完成。") + + +def migrate_local(): + # 加载 .env + env_path = os.path.join(PROJECT_ROOT, ".env") + if os.path.isfile(env_path): + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, _, v = line.partition("=") + os.environ[k.strip()] = v.strip() + if "DATABASE_URL_SYNC" not in os.environ: + os.environ.setdefault("DATABASE_URL_SYNC", "postgresql://wecom:wecom_secret@localhost:5432/wecom_ai") + os.chdir(BACKEND_DIR) + os.environ["PYTHONPATH"] = BACKEND_DIR + print("本机执行迁移(backend 目录)...") + run("alembic upgrade head", cwd=BACKEND_DIR) + print("迁移完成。") + + +def main(): + if "--local" in sys.argv: + migrate_local() + else: + migrate_docker() + + +if __name__ == "__main__": + main() diff --git a/deploy/scripts/migrate.sh b/deploy/scripts/migrate.sh new file mode 100644 index 0000000..017c1d9 --- /dev/null +++ b/deploy/scripts/migrate.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 一键迁移(Docker):在项目根目录执行 +# 用法: bash deploy/scripts/migrate.sh 或 ./deploy/scripts/migrate.sh + +set -e +cd "$(dirname "$0")/../.." + +echo "[1/3] 启动数据库..." +docker compose up -d db + +echo "[2/3] 等待数据库就绪..." +max=30 +for i in $(seq 1 $max); do + if docker compose exec -T db pg_isready -U wecom -d wecom_ai 2>/dev/null; then + break + fi + if [ "$i" -eq "$max" ]; then + echo "数据库未在 ${max}s 内就绪,请检查 docker compose logs db" + exit 1 + fi + sleep 1 +done + +echo "[3/3] 执行 Alembic 迁移..." +docker compose run --rm backend sh -c "alembic upgrade head" + +echo "迁移完成。" diff --git a/deploy/scripts/seed.py b/deploy/scripts/seed.py new file mode 100644 index 0000000..a6d57bf --- /dev/null +++ b/deploy/scripts/seed.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +初始化管理员账号。从项目根目录的 .env 读取 ADMIN_USERNAME、ADMIN_PASSWORD(可选), +未设置则使用默认 admin / admin。需先执行 Alembic 迁移。 +用法(在项目根目录): + python deploy/scripts/seed.py + 或:cd backend && PYTHONPATH=. python ../deploy/scripts/seed.py +""" +import os +import sys +import uuid + +# 允许从项目根或 backend 运行 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "backend")) + +from dotenv import load_dotenv + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env")) + +from sqlalchemy import create_engine, select, text +from sqlalchemy.orm import sessionmaker + +# 同步连接,供脚本使用 +DATABASE_URL_SYNC = os.getenv( + "DATABASE_URL_SYNC", + "postgresql://wecom:wecom_secret@localhost:5432/wecom_ai", +) + + +def main(): + import bcrypt + + username = os.getenv("ADMIN_USERNAME", "admin") + password = os.getenv("ADMIN_PASSWORD", "admin") + password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + engine = create_engine(DATABASE_URL_SYNC) + Session = sessionmaker(bind=engine) + with Session() as session: + # 检查 users 表是否存在 + try: + session.execute(text("SELECT 1 FROM users LIMIT 1")) + except Exception: + print("表 users 不存在,请先执行: cd backend && alembic upgrade head") + sys.exit(1) + + # 是否已存在该用户名 + r = session.execute( + text("SELECT id FROM users WHERE username = :u"), + {"u": username}, + ) + if r.one_or_none(): + print(f"用户 {username} 已存在,跳过创建。") + return + + user_id = uuid.uuid4() + session.execute( + text( + "INSERT INTO users (id, username, password_hash, role, is_active, created_at) " + "VALUES (:id, :u, :p, 'admin', true, NOW())" + ), + {"id": user_id, "u": username, "p": password_hash}, + ) + session.commit() + print(f"已创建管理员: username={username}") + + +if __name__ == "__main__": + main() diff --git a/deploy/scripts/setup-ssl.sh b/deploy/scripts/setup-ssl.sh new file mode 100644 index 0000000..285433a --- /dev/null +++ b/deploy/scripts/setup-ssl.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# SSL 证书配置脚本(Let's Encrypt) +# 用途:为备案域名配置 HTTPS 证书 + +set -e + +if [ -z "$DOMAIN" ]; then + echo "错误: 未设置 DOMAIN 环境变量" + echo "请设置: export DOMAIN=your-domain.com" + exit 1 +fi + +if [ -z "$SSL_EMAIL" ]; then + echo "错误: 未设置 SSL_EMAIL 环境变量" + echo "请设置: export SSL_EMAIL=your-email@example.com" + exit 1 +fi + +echo "=== SSL 证书配置(Let's Encrypt)===" +echo "域名: $DOMAIN" +echo "邮箱: $SSL_EMAIL" +echo "" + +# 检查 Certbot +if ! command -v certbot &> /dev/null; then + echo "安装 Certbot..." + if [ -f /etc/debian_version ]; then + sudo apt-get update + sudo apt-get install -y certbot python3-certbot-nginx + elif [ -f /etc/redhat-release ]; then + sudo yum install -y certbot python3-certbot-nginx + else + echo "错误: 未检测到支持的 Linux 发行版" + exit 1 + fi +fi + +echo "[1/3] 确保 HTTP 服务运行(用于验证)..." +docker-compose up -d backend nginx +sleep 3 + +echo "[2/3] 获取 SSL 证书..." +sudo certbot certonly --nginx \ + -d "$DOMAIN" \ + -d "www.$DOMAIN" \ + --email "$SSL_EMAIL" \ + --agree-tos \ + --non-interactive \ + --preferred-challenges http + +echo "[3/3] 更新 Nginx 配置..." +# 更新 nginx-ssl.conf,使用实际证书路径 +sed -i "s|ssl_certificate.*|ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;|" deploy/nginx-ssl.conf +sed -i "s|ssl_certificate_key.*|ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;|" deploy/nginx-ssl.conf + +# 更新 docker-compose.yml,挂载证书目录 +# 注意:需要手动更新 docker-compose.yml 的 volumes + +echo "✓ SSL 证书配置完成" +echo "" +echo "证书路径: /etc/letsencrypt/live/$DOMAIN/" +echo "" +echo "请更新 docker-compose.yml,添加证书挂载:" +echo " volumes:" +echo " - /etc/letsencrypt:/etc/letsencrypt:ro" +echo "" +echo "然后重启 Nginx: docker-compose restart nginx" diff --git a/deploy/scripts/start.sh b/deploy/scripts/start.sh new file mode 100644 index 0000000..443bfde --- /dev/null +++ b/deploy/scripts/start.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# 生产环境启动脚本 +# 用途:启动生产服务 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$PROJECT_ROOT" + +echo "=== 启动生产服务 ===" +echo "" + +# 检查 .env.prod 文件 +if [ ! -f ".env.prod" ]; then + echo "错误: 未找到 .env.prod 文件" + echo "请复制 .env.example 为 .env.prod 并填写生产环境变量" + exit 1 +fi + +# 检查必需的环境变量 +source .env.prod +required_vars=("WECOM_TOKEN" "WECOM_ENCODING_AES_KEY" "WECOM_CORP_ID" "WECOM_AGENT_ID") +for var in "${required_vars[@]}"; do + if [ -z "${!var}" ]; then + echo "错误: .env.prod 中未设置 $var" + exit 1 + fi +done + +# 设置镜像标签(默认 latest) +IMAGE_TAG=${IMAGE_TAG:-latest} +export IMAGE_TAG + +# 启动服务 +echo "使用镜像标签: $IMAGE_TAG" +echo "" + +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d + +echo "" +echo "等待服务启动..." +sleep 5 + +# 检查服务状态 +echo "" +echo "服务状态:" +docker-compose -f docker-compose.prod.yml ps + +echo "" +echo "=== 启动完成 ===" +echo "" +echo "查看日志: docker-compose -f docker-compose.prod.yml logs -f" +echo "检查健康: curl http://localhost/api/health" diff --git a/deploy/scripts/stop.sh b/deploy/scripts/stop.sh new file mode 100644 index 0000000..1d411f7 --- /dev/null +++ b/deploy/scripts/stop.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# 生产环境停止脚本 +# 用途:停止生产服务 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$PROJECT_ROOT" + +echo "=== 停止生产服务 ===" +echo "" + +docker-compose -f docker-compose.prod.yml --env-file .env.prod down + +echo "" +echo "=== 停止完成 ===" diff --git a/deploy/scripts/update.sh b/deploy/scripts/update.sh new file mode 100644 index 0000000..825c684 --- /dev/null +++ b/deploy/scripts/update.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# 生产环境更新脚本 +# 用途:拉取最新镜像并重启服务 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +cd "$PROJECT_ROOT" + +# 检查参数 +IMAGE_TAG=${1:-latest} +if [ -z "$IMAGE_TAG" ]; then + echo "用法: $0 [IMAGE_TAG]" + echo "示例: $0 latest" + echo "示例: $0 v1.0.0" + exit 1 +fi + +echo "=== 更新生产服务 ===" +echo "镜像标签: $IMAGE_TAG" +echo "" + +# 检查 .env.prod 文件 +if [ ! -f ".env.prod" ]; then + echo "错误: 未找到 .env.prod 文件" + exit 1 +fi + +# 设置镜像标签 +export IMAGE_TAG + +# 登录到容器镜像仓库(如果需要) +# docker login ghcr.io -u $GITHUB_USERNAME -p $GITHUB_TOKEN + +# 拉取最新镜像 +echo "[1/3] 拉取最新镜像..." +docker-compose -f docker-compose.prod.yml --env-file .env.prod pull + +# 停止旧服务 +echo "" +echo "[2/3] 停止旧服务..." +docker-compose -f docker-compose.prod.yml --env-file .env.prod down + +# 启动新服务 +echo "" +echo "[3/3] 启动新服务..." +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d + +echo "" +echo "等待服务启动..." +sleep 5 + +# 检查服务状态 +echo "" +echo "服务状态:" +docker-compose -f docker-compose.prod.yml ps + +echo "" +echo "=== 更新完成 ===" +echo "" +echo "查看日志: docker-compose -f docker-compose.prod.yml logs -f" +echo "检查健康: curl http://localhost/api/health" diff --git a/docker-compose.minimal.yml b/docker-compose.minimal.yml new file mode 100644 index 0000000..5883437 --- /dev/null +++ b/docker-compose.minimal.yml @@ -0,0 +1,60 @@ +# 最小回调壳部署配置(仅 backend + nginx) +# 用途:云端最小可用部署,用于企业微信回调联调 +# 使用: docker-compose -f docker-compose.minimal.yml up -d + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + env_file: .env + environment: + # 数据库连接(可选,如果不需要数据库可以先注释) + # DATABASE_URL: postgresql+asyncpg://wecom:wecom_secret@db:5432/wecom_ai + # DATABASE_URL_SYNC: postgresql://wecom:wecom_secret@db:5432/wecom_ai + ports: + - "8000:8000" + # 最小回调壳不需要数据库依赖 + # depends_on: + # db: + # condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + # 使用 HTTPS 配置(需要 SSL 证书) + - ./deploy/nginx-ssl.conf:/etc/nginx/nginx.conf:ro + # SSL 证书挂载(Let's Encrypt) + - /etc/letsencrypt:/etc/letsencrypt:ro + depends_on: + - backend + restart: unless-stopped + + # 数据库(可选,最小回调壳可以先不启用) + # db: + # image: postgres:16-alpine + # environment: + # POSTGRES_USER: wecom + # POSTGRES_PASSWORD: wecom_secret + # POSTGRES_DB: wecom_ai + # volumes: + # - pgdata:/var/lib/postgresql/data + # ports: + # - "5432:5432" + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U wecom -d wecom_ai"] + # interval: 5s + # timeout: 5s + # retries: 5 + +# volumes: +# pgdata: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..812eb4b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,94 @@ +# 生产环境 Docker Compose 配置 +# 用途:云端生产部署 +# 使用: docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d + +version: '3.8' + +services: + backend: + image: ghcr.io/${GITHUB_REPOSITORY_OWNER:-your-org}/wecom-ai-backend:${IMAGE_TAG:-latest} + build: + context: . + dockerfile: deploy/docker/backend.Dockerfile + env_file: + - .env.prod + environment: + # 数据库连接(如果启用 db 服务) + # DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-wecom}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-wecom_ai} + # DATABASE_URL_SYNC: postgresql://${POSTGRES_USER:-wecom}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-wecom_ai} + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - app-network + + # Admin 服务(可选,最小回调壳可以先不启用) + # admin: + # image: ghcr.io/${GITHUB_REPOSITORY_OWNER:-your-org}/wecom-ai-admin:${IMAGE_TAG:-latest} + # build: + # context: . + # dockerfile: deploy/docker/admin.Dockerfile + # env_file: + # - .env.prod + # restart: unless-stopped + # healthcheck: + # test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 40s + # networks: + # - app-network + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./deploy/docker/nginx.conf:/etc/nginx/nginx.conf:ro + # SSL 证书挂载(Let's Encrypt) + - /etc/letsencrypt:/etc/letsencrypt:ro + # Certbot 验证文件 + - /var/www/certbot:/var/www/certbot:ro + # Nginx 日志 + - ./logs/nginx:/var/log/nginx + depends_on: + backend: + condition: service_healthy + # admin: + # condition: service_healthy + restart: unless-stopped + networks: + - app-network + + # 数据库服务(可选,最小回调壳可以先不启用) + # db: + # image: postgres:16-alpine + # env_file: + # - .env.prod + # environment: + # POSTGRES_USER: ${POSTGRES_USER:-wecom} + # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # POSTGRES_DB: ${POSTGRES_DB:-wecom_ai} + # volumes: + # - pgdata:/var/lib/postgresql/data + # restart: unless-stopped + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wecom} -d ${POSTGRES_DB:-wecom_ai}"] + # interval: 10s + # timeout: 5s + # retries: 5 + # networks: + # - app-network + +networks: + app-network: + driver: bridge + +# volumes: +# pgdata: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc365c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: wecom + POSTGRES_PASSWORD: wecom_secret + POSTGRES_DB: wecom_ai + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wecom -d wecom_ai"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + env_file: .env + environment: + DATABASE_URL: postgresql+asyncpg://wecom:wecom_secret@db:5432/wecom_ai + DATABASE_URL_SYNC: postgresql://wecom:wecom_secret@db:5432/wecom_ai + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + admin: + build: + context: ./admin + dockerfile: Dockerfile + env_file: .env + ports: + - "3000:3000" + depends_on: + - backend + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./deploy/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - admin + +volumes: + pgdata: diff --git a/docs/cloudflared-quickstart.md b/docs/cloudflared-quickstart.md new file mode 100644 index 0000000..def3849 --- /dev/null +++ b/docs/cloudflared-quickstart.md @@ -0,0 +1,151 @@ +# Cloudflare Tunnel 快速开始指南 + +## 一键安装(Windows) + +### 方法 1:使用 MSI 安装包(最简单) + +1. **下载安装包**: + - 直接下载:https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.msi + - 或访问:https://github.com/cloudflare/cloudflared/releases 选择最新版本的 MSI 文件 + +2. **安装**: + - 双击 `cloudflared-windows-amd64.msi` + - 按照安装向导完成安装 + - 安装完成后会自动添加到系统 PATH + +3. **验证安装**: + ```powershell + cloudflared --version + ``` + +### 方法 2:使用 Scoop(推荐开发者) + +```powershell +# 安装 Scoop(如果还没有) +Set-ExecutionPolicy RemoteSigned -Scope CurrentUser +irm get.scoop.sh | iex + +# 安装 cloudflared +scoop install cloudflared + +# 验证 +cloudflared --version +``` + +### 方法 3:直接下载 EXE(无需安装) + +1. **下载**:https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe +2. **重命名**:将文件重命名为 `cloudflared.exe` +3. **使用**:在项目目录中直接运行 `.\cloudflared.exe tunnel --url http://localhost:8000` + +--- + +## 快速启动 Tunnel + +### 步骤 1:确保后端服务运行 + +```powershell +# 检查服务状态 +docker compose ps + +# 如果未运行,启动服务 +docker compose up -d +``` + +### 步骤 2:启动 Cloudflare Tunnel + +```powershell +# 在项目根目录运行 +cloudflared tunnel --url http://localhost:8000 +``` + +**输出示例**: +``` +2025-02-05T10:00:00Z INF +--------------------------------------------------------------------------------------------+ +2025-02-05T10:00:00Z INF | Your quick Tunnel has been created! Visit it at: | +2025-02-05T10:00:00Z INF | https://abc123-def456-ghi789.trycloudflare.com | +2025-02-05T10:00:00Z INF +--------------------------------------------------------------------------------------------+ +``` + +### 步骤 3:复制公网 URL + +从输出中复制 `https://xxx.trycloudflare.com`,例如: +``` +https://abc123-def456-ghi789.trycloudflare.com +``` + +### 步骤 4:配置企业微信回调 + +1. 登录企业微信管理后台:https://work.weixin.qq.com +2. 进入:应用管理 → 自建应用 → 你的应用 → 接收消息 → 设置 API 接收 +3. 填写回调 URL:`https://abc123-def456-ghi789.trycloudflare.com/api/wecom/callback` +4. 填写 Token 和 EncodingAESKey(与 `.env` 文件一致) +5. 点击保存 + +### 步骤 5:验证配置 + +```powershell +# 在另一个终端查看后端日志 +docker compose logs backend -f +``` + +应该看到: +``` +INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} +``` + +企微后台应显示 **保存成功** ✅ + +--- + +## 测试消息回调 + +1. **在企业微信中发送消息**:`你好,测试一下` +2. **查看后端日志**: + ```powershell + docker compose logs backend -f + ``` + 应该看到: + - `wecom message received`(收到消息) + - `wecom reply sent`(发送回复) +3. **在企业微信中验证**:应收到回复 `已收到:你好,测试一下` + +--- + +## 重要提示 + +1. **保持 cloudflared 运行**:不要关闭运行 cloudflared 的终端窗口 +2. **URL 有效期**:本次运行期间 URL 固定,关闭 cloudflared 后 URL 失效 +3. **如需固定域名**:登录 Cloudflare 创建命名 tunnel(参见 `docs/cloudflared-setup.md`) + +--- + +## 常见问题 + +### Q: cloudflared 命令找不到? + +**A**: +- 如果使用 MSI 安装,重启终端或重新打开 PowerShell +- 如果手动下载,确保文件在 PATH 中或使用完整路径 + +### Q: 连接失败? + +**A**: +- 检查本地服务是否运行:`docker compose ps` +- 检查端口是否正确:`netstat -an | findstr 8000` +- 检查防火墙设置 + +### Q: 企微回调失败? + +**A**: +- 确保 cloudflared URL 可访问:在浏览器打开 `https://你的域名.trycloudflare.com/api/health` +- 检查 Token 和 EncodingAESKey 是否与 `.env` 一致 +- 查看后端日志:`docker compose logs backend | grep wecom` + +--- + +## 更多信息 + +- **详细设置指南**:`docs/cloudflared-setup.md` +- **完整测试流程**:`docs/wecom-test-guide.md` +- **官方文档**:https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/ diff --git a/docs/cloudflared-setup.md b/docs/cloudflared-setup.md new file mode 100644 index 0000000..f5f7ddc --- /dev/null +++ b/docs/cloudflared-setup.md @@ -0,0 +1,312 @@ +# 使用 Cloudflare Tunnel 设置公网域名 + +Cloudflare Tunnel (cloudflared) 是 Cloudflare 提供的免费内网穿透服务,相比 ngrok 的优势: +- ✅ 提供固定的免费域名(不会每次重启都变化) +- ✅ 免费且稳定 +- ✅ 支持 HTTPS(自动配置 SSL 证书) + +--- + +## 一、安装 cloudflared + +### Windows 安装方法 + +#### 方法 1:使用 Scoop(推荐) + +```powershell +# 安装 Scoop(如果还没有) +Set-ExecutionPolicy RemoteSigned -Scope CurrentUser +irm get.scoop.sh | iex + +# 安装 cloudflared +scoop install cloudflared +``` + +#### 方法 2:使用 Chocolatey + +```powershell +# 安装 Chocolatey(如果还没有) +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + +# 安装 cloudflared +choco install cloudflared +``` + +#### 方法 3:手动下载(推荐用于快速测试) + +1. **访问发布页面**:https://github.com/cloudflare/cloudflared/releases +2. **下载最新版本**: + - 找到最新的发布版本(例如:`2026.1.2`) + - 下载 Windows 64位版本:`cloudflared-windows-amd64.exe` + - 或下载 MSI 安装包:`cloudflared-windows-amd64.msi`(自动安装到系统) +3. **使用方式**: + - **方式 A(直接使用)**:将 `cloudflared-windows-amd64.exe` 重命名为 `cloudflared.exe`,放到项目目录或任意目录 + - **方式 B(添加到 PATH)**:将文件放到 PATH 环境变量中的目录(如 `C:\Windows\System32`),这样可以在任何地方使用 `cloudflared` 命令 + - **方式 C(MSI 安装)**:双击 `cloudflared-windows-amd64.msi` 安装,会自动添加到系统 PATH + +**最新版本下载链接**(直接下载): +- Windows 64位 EXE:https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe +- Windows 64位 MSI:https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.msi + +--- + +## 二、快速启动(无需登录,临时使用) + +### 2.1 启动 Tunnel + +```powershell +# 在项目根目录运行 +cloudflared tunnel --url http://localhost:8000 +``` + +**输出示例**: +``` +2025-02-05T10:00:00Z INF +--------------------------------------------------------------------------------------------+ +2025-02-05T10:00:00Z INF | Your quick Tunnel has been created! Visit it at: | +2025-02-05T10:00:00Z INF | https://abc123-def456-ghi789.trycloudflare.com | +2025-02-05T10:00:00Z INF +--------------------------------------------------------------------------------------------+ +``` + +### 2.2 复制公网 URL + +从输出中复制 `https://xxx.trycloudflare.com`,这就是你的公网域名。 + +**注意**: +- 这个 URL 在本次运行期间是固定的 +- 关闭 cloudflared 后,URL 会失效 +- 每次重新启动会生成新的 URL + +### 2.3 配置企业微信回调 + +在企微后台配置回调 URL: +``` +https://你的cloudflared域名.trycloudflare.com/api/wecom/callback +``` + +例如:`https://abc123-def456-ghi789.trycloudflare.com/api/wecom/callback` + +--- + +## 三、使用固定域名(推荐,需要 Cloudflare 账号) + +### 3.1 登录 Cloudflare + +```powershell +cloudflared tunnel login +``` + +这会打开浏览器,选择你的域名(如果没有域名,可以跳过,使用免费域名)。 + +### 3.2 创建命名 Tunnel + +```powershell +# 创建名为 wecom-callback 的 tunnel +cloudflared tunnel create wecom-callback +``` + +### 3.3 配置 Tunnel + +创建配置文件 `~/.cloudflared/config.yml`(Windows 路径:`C:\Users\你的用户名\.cloudflared\config.yml`): + +```yaml +tunnel: wecom-callback +credentials-file: C:\Users\你的用户名\.cloudflared\.json + +ingress: + - hostname: wecom-callback.your-domain.com # 你的域名(如果有) + service: http://localhost:8000 + - service: http_status:404 +``` + +**如果没有域名,使用免费域名**: + +```yaml +tunnel: wecom-callback +credentials-file: C:\Users\你的用户名\.cloudflared\.json + +ingress: + - service: http://localhost:8000 +``` + +### 3.4 启动 Tunnel + +```powershell +cloudflared tunnel run wecom-callback +``` + +--- + +## 四、后台运行(Windows) + +### 方法 1:使用 PowerShell 后台任务 + +```powershell +# 启动后台任务 +Start-Process -NoNewWindow cloudflared -ArgumentList "tunnel --url http://localhost:8000" + +# 查看进程 +Get-Process cloudflared + +# 停止进程 +Stop-Process -Name cloudflared +``` + +### 方法 2:创建 Windows 服务(固定域名方式) + +```powershell +# 安装为 Windows 服务 +cloudflared service install + +# 启动服务 +net start cloudflared + +# 停止服务 +net stop cloudflared + +# 卸载服务 +cloudflared service uninstall +``` + +--- + +## 五、验证 Tunnel 是否工作 + +### 5.1 测试本地端点 + +```powershell +# 测试本地服务 +curl http://localhost:8000/api/health + +# 应该返回:{"status":"up","service":"backend"} +``` + +### 5.2 测试公网端点 + +```powershell +# 测试公网 URL(替换为你的 cloudflared URL) +curl https://你的域名.trycloudflare.com/api/health + +# 应该返回:{"status":"up","service":"backend"} +``` + +--- + +## 六、完整测试流程 + +### 6.1 启动服务 + +```powershell +# 终端 1:启动 Docker 服务 +docker compose up -d + +# 终端 2:启动 cloudflared +cloudflared tunnel --url http://localhost:8000 +``` + +### 6.2 配置企微回调 + +1. 复制 cloudflared 提供的 URL(例如:`https://abc123-def456-ghi789.trycloudflare.com`) +2. 在企微后台配置回调 URL:`https://abc123-def456-ghi789.trycloudflare.com/api/wecom/callback` +3. 填写 Token 和 EncodingAESKey(与 `.env` 一致) +4. 点击保存 + +### 6.3 验证 GET 校验 + +```powershell +# 查看后端日志 +docker compose logs backend -f + +# 应该看到: +# INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} +``` + +### 6.4 测试消息回调 + +1. 在企业微信中发送消息 +2. 查看后端日志确认收到消息和发送回复 +3. 在企业微信中验证收到回复 + +--- + +## 七、常见问题 + +### 问题 1:cloudflared 连接失败 + +**解决方案**: +- 检查本地服务是否运行:`docker compose ps` +- 检查端口是否正确:`netstat -an | findstr 8000` +- 检查防火墙是否阻止了 cloudflared + +### 问题 2:企微回调失败 + +**解决方案**: +- 确保 cloudflared URL 可访问:在浏览器中打开 `https://你的域名.trycloudflare.com/api/health` +- 检查 Token 和 EncodingAESKey 是否一致 +- 查看后端日志:`docker compose logs backend | grep wecom` + +### 问题 3:cloudflared 进程意外退出 + +**解决方案**: +- 使用后台运行方式(见上方) +- 或使用 Windows 服务方式 +- 检查 cloudflared 日志 + +--- + +## 八、与 ngrok 对比 + +| 特性 | cloudflared | ngrok | +|------|-------------|-------| +| 免费域名 | ✅ 固定(登录后) | ❌ 每次变化(免费版) | +| 安装 | 简单 | 简单 | +| 稳定性 | 高 | 中等 | +| 速度 | 快 | 中等 | +| 推荐 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | + +--- + +## 九、快速命令参考 + +```powershell +# 启动临时 tunnel +cloudflared tunnel --url http://localhost:8000 + +# 登录 Cloudflare +cloudflared tunnel login + +# 创建命名 tunnel +cloudflared tunnel create wecom-callback + +# 运行命名 tunnel +cloudflared tunnel run wecom-callback + +# 列出所有 tunnel +cloudflared tunnel list + +# 删除 tunnel +cloudflared tunnel delete wecom-callback + +# 查看 tunnel 信息 +cloudflared tunnel info wecom-callback +``` + +--- + +## 十、推荐配置(生产环境) + +对于生产环境,建议: + +1. **使用固定域名**:登录 Cloudflare,创建命名 tunnel +2. **配置为 Windows 服务**:确保自动启动 +3. **监控 tunnel 状态**:设置日志和告警 + +```powershell +# 创建配置文件后,安装为服务 +cloudflared service install + +# 启动服务 +net start cloudflared + +# 查看服务状态 +sc query cloudflared +``` diff --git a/docs/deploy-cloud-minimal.md b/docs/deploy-cloud-minimal.md new file mode 100644 index 0000000..bd493bc --- /dev/null +++ b/docs/deploy-cloud-minimal.md @@ -0,0 +1,415 @@ +# 云端最小回调壳部署方案 + +## 一、目标 + +**阶段目标**:在备案域名上部署最小可用回调壳,使企业微信能完成 URL 校验与回调联调。 + +**最小功能范围**: +- ✅ `/api/wecom/callback` GET 校验(兼容 `signature`/`msg_signature`) +- ✅ `/api/wecom/callback` POST 密文消息回调(验签、解密、echo 回复) +- ✅ 结构化日志 + trace_id +- ✅ Nginx 反代 + HTTPS(Let's Encrypt) +- ⏸️ 数据库(可先不启用,但接口与配置要预留) +- ⏸️ Admin 后台(可先占位) + +--- + +## 二、架构 + +``` +企业微信 → HTTPS (443) → Nginx → Backend (8000) + ↓ + PostgreSQL (可选) +``` + +**服务清单**: +- `backend`: Python 3.12 + FastAPI + Uvicorn(最小回调壳) +- `nginx`: 反代 + HTTPS(Let's Encrypt) +- `db`: PostgreSQL 16(可选,先不启用) + +--- + +## 三、环境变量配置 + +### 3.1 必需变量(`.env`) + +```bash +# ============ Backend ============ +API_HOST=0.0.0.0 +API_PORT=8000 + +# Database(可选,先不启用) +DATABASE_URL=postgresql+asyncpg://wecom:wecom_secret@db:5432/wecom_ai +DATABASE_URL_SYNC=postgresql://wecom:wecom_secret@db:5432/wecom_ai + +# JWT(admin 登录,可选) +JWT_SECRET=your-jwt-secret-change-in-production +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=60 + +# WeCom Callback(必须,从企业微信管理后台获取) +WECOM_CORP_ID=你的企业ID +WECOM_AGENT_ID=你的应用AgentId +WECOM_SECRET=你的应用Secret(可选,用于主动发送消息) +WECOM_TOKEN=你的Token(必须与企微后台一致) +WECOM_ENCODING_AES_KEY=你的43位密钥(必须与企微后台一致) + +# WeCom API +WECOM_API_BASE=https://qyapi.weixin.qq.com +WECOM_API_TIMEOUT=10 +WECOM_API_RETRIES=2 + +# Log +LOG_LEVEL=INFO +LOG_JSON=true + +# ============ Nginx ============ +# 域名(必须,备案域名) +DOMAIN=your-domain.com + +# SSL(Let's Encrypt) +SSL_EMAIL=your-email@example.com +``` + +### 3.2 关键变量说明 + +| 变量 | 说明 | 来源 | +|------|------|------| +| `WECOM_TOKEN` | 企业微信回调 Token | 企微后台 → 应用 → 接收消息 → Token | +| `WECOM_ENCODING_AES_KEY` | 43 位 Base64 编码密钥 | 企微后台 → 应用 → 接收消息 → EncodingAESKey | +| `WECOM_CORP_ID` | 企业 ID | 企微后台 → 我的企业 → 企业信息 | +| `WECOM_AGENT_ID` | 应用 AgentId | 企微后台 → 应用管理 → 自建应用 → 应用详情 | +| `DOMAIN` | 备案域名 | 你的域名服务商 | + +--- + +## 四、部署步骤 + +### 4.1 前置条件 + +1. **备案域名**:已备案且主体关联的域名(例如:`api.yourdomain.com`) +2. **服务器**:Linux(Ubuntu 20.04+ / CentOS 7+),公网 IP,开放 80/443 端口 +3. **Docker**:已安装 Docker 和 docker-compose +4. **GitHub**:代码已推送到 GitHub(用于 CI/CD) + +### 4.2 服务器初始化 + +```bash +# 1. 安装 Docker 和 docker-compose +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +# 2. 克隆项目(或通过 CI/CD 部署) +git clone https://github.com/your-org/wecom-ai-assistant.git +cd wecom-ai-assistant + +# 3. 创建 .env 文件 +cp .env.example .env +# 编辑 .env,填入上述必需变量 +``` + +### 4.3 配置 Nginx + HTTPS + +#### 方案 A:使用 Certbot(Let's Encrypt) + +```bash +# 1. 安装 Certbot +sudo apt-get update +sudo apt-get install certbot python3-certbot-nginx + +# 2. 先启动 HTTP 服务(用于验证) +docker-compose up -d backend + +# 3. 配置 Nginx(临时 HTTP 配置) +# 编辑 deploy/nginx.conf,添加 server_name +# 然后运行:docker-compose up -d nginx + +# 4. 获取 SSL 证书 +sudo certbot --nginx -d your-domain.com -d www.your-domain.com --email your-email@example.com --agree-tos --non-interactive + +# 5. 更新 nginx.conf,使用 Certbot 生成的配置 +# Certbot 会自动修改 /etc/nginx/sites-available/default +# 将配置复制到 deploy/nginx.conf,或使用 volume 挂载 +``` + +#### 方案 B:手动配置 Nginx + Let's Encrypt + +创建 `deploy/nginx-ssl.conf`: + +```nginx +events { worker_connections 1024; } + +http { + upstream backend { + server backend:8000; + } + + # HTTP → HTTPS 重定向 + server { + listen 80; + server_name your-domain.com www.your-domain.com; + return 301 https://$server_name$request_uri; + } + + # HTTPS + server { + listen 443 ssl http2; + server_name your-domain.com www.your-domain.com; + + # SSL 证书(Let's Encrypt) + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # /api -> backend + location /api/ { + proxy_pass http://backend; + 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_read_timeout 30s; + } + + # 健康检查 + location /health { + proxy_pass http://backend/health; + access_log off; + } + } +} +``` + +更新 `docker-compose.yml`: + +```yaml +nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./deploy/nginx-ssl.conf:/etc/nginx/nginx.conf:ro + - /etc/letsencrypt:/etc/letsencrypt:ro # SSL 证书 + depends_on: + - backend +``` + +### 4.4 启动服务 + +```bash +# 1. 构建镜像 +docker-compose build backend + +# 2. 启动服务(最小回调壳:只启动 backend + nginx) +docker-compose up -d backend nginx + +# 3. 检查日志 +docker-compose logs -f backend +``` + +### 4.5 验证服务 + +```bash +# 1. 检查服务状态 +docker-compose ps + +# 2. 检查健康检查 +curl https://your-domain.com/health + +# 3. 检查回调接口(应返回 400,因为缺少参数) +curl https://your-domain.com/api/wecom/callback +``` + +--- + +## 五、本地验证 + +### 5.1 本地启动最小回调壳 + +```bash +# 1. 启动后端(不启动 db/admin) +docker-compose up -d backend + +# 2. 检查日志 +docker-compose logs backend + +# 3. 测试 GET 校验(模拟企业微信) +# 注意:需要正确的 signature/timestamp/nonce/echostr +curl "http://localhost:8000/api/wecom/callback?signature=xxx×tamp=123&nonce=abc&echostr=xxx" +``` + +### 5.2 本地测试 POST 回调 + +```bash +# 使用企业微信官方测试工具生成测试请求 +# 或使用 curl 模拟(需要正确的签名和加密) +curl -X POST "http://localhost:8000/api/wecom/callback?msg_signature=xxx×tamp=123&nonce=abc" \ + -H "Content-Type: application/xml" \ + -d '' +``` + +### 5.3 验证日志格式 + +检查日志输出是否符合结构化日志格式: + +```json +{ + "timestamp": "2025-02-05T10:00:00Z", + "level": "INFO", + "message": "wecom verify success", + "trace_id": "abc123", + "echostr_length": 43 +} +``` + +--- + +## 六、线上验证 + +### 6.1 企业微信后台配置 + +1. **登录企业微信管理后台**:https://work.weixin.qq.com +2. **进入应用设置**:应用管理 → 自建应用 → 选择你的应用 +3. **配置回调 URL**: + - 接收消息服务器 URL:`https://your-domain.com/api/wecom/callback` + - Token:与 `.env` 中的 `WECOM_TOKEN` **完全一致** + - EncodingAESKey:与 `.env` 中的 `WECOM_ENCODING_AES_KEY` **完全一致** + - 消息加解密方式:**安全模式** +4. **点击保存** + +### 6.2 验证 GET 校验 + +保存后,企业微信会立即发送 GET 请求验证。观察后端日志: + +```bash +docker-compose logs -f backend +``` + +**成功日志**: +``` +INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} +``` + +**失败日志**: +``` +WARNING: wecom verify failed {"trace_id": "...", "timestamp": "...", "nonce": "..."} +``` + +如果验证失败,检查: +- Token 是否一致 +- EncodingAESKey 是否一致 +- 域名是否可访问(`curl https://your-domain.com/api/wecom/callback`) + +### 6.3 验证 POST 回调 + +1. **在企业微信中发送测试消息**: + - 打开企业微信客户端 + - 找到你配置的应用 + - 发送文本消息:`你好,测试一下` + +2. **观察后端日志**: +```bash +docker-compose logs -f backend +``` + +**成功日志**: +```json +{ + "timestamp": "2025-02-05T10:00:00Z", + "level": "INFO", + "message": "wecom message received", + "trace_id": "abc123", + "external_userid": "external_userid_xxx", + "msgid": "123456", + "msg_type": "text", + "content_summary": "你好,测试一下" +} +``` + +```json +{ + "timestamp": "2025-02-05T10:00:01Z", + "level": "INFO", + "message": "wecom reply sent", + "trace_id": "abc123", + "external_userid": "external_userid_xxx", + "msgid": "123456", + "reply_summary": "已收到:你好,测试一下" +} +``` + +3. **检查企业微信客户端**:应收到回复:`已收到:你好,测试一下` + +--- + +## 七、常见问题 + +### 7.1 GET 校验失败 + +**原因**: +- Token 不一致 +- EncodingAESKey 不一致 +- 签名算法错误 + +**解决**: +1. 检查 `.env` 中的 `WECOM_TOKEN` 和 `WECOM_ENCODING_AES_KEY` +2. 确保与企微后台配置**完全一致**(包括大小写、空格) +3. 重启后端:`docker-compose restart backend` + +### 7.2 POST 回调失败 + +**原因**: +- 签名验证失败 +- 解密失败 +- XML 解析失败 + +**解决**: +1. 检查日志中的错误信息 +2. 确认 EncodingAESKey 正确 +3. 确认消息加解密方式为**安全模式** + +### 7.3 HTTPS 证书问题 + +**原因**: +- Let's Encrypt 证书未正确配置 +- 证书过期 + +**解决**: +1. 检查证书:`sudo certbot certificates` +2. 续期证书:`sudo certbot renew` +3. 重启 Nginx:`docker-compose restart nginx` + +### 7.4 域名无法访问 + +**原因**: +- DNS 未解析 +- 防火墙未开放 80/443 端口 +- Nginx 配置错误 + +**解决**: +1. 检查 DNS:`nslookup your-domain.com` +2. 检查端口:`netstat -tlnp | grep -E '80|443'` +3. 检查 Nginx 日志:`docker-compose logs nginx` + +--- + +## 八、下一步 + +完成最小回调壳部署后,按以下顺序逐步接入: + +1. ✅ **最小回调壳**(当前阶段) +2. ⏭️ **数据库接入**(会话与消息入库) +3. ⏭️ **Admin 后台**(会话列表、工单、知识库) +4. ⏭️ **FAQ/RAG**(智能回复) + +--- + +## 九、参考文档 + +- [企业微信回调配置](./wecom.md) +- [企业微信测试指南](./wecom-test-guide.md) +- [GitHub Actions CI/CD](../deploy/ci/github-actions.yml) diff --git a/docs/deploy-quick-reference.md b/docs/deploy-quick-reference.md new file mode 100644 index 0000000..6317b14 --- /dev/null +++ b/docs/deploy-quick-reference.md @@ -0,0 +1,106 @@ +# 生产部署快速参考 + +## 一、首次部署(5 分钟) + +```bash +# 1. 克隆项目 +git clone https://github.com/your-org/wecom-ai-assistant.git +cd wecom-ai-assistant + +# 2. 配置环境变量 +cp .env.prod.example .env.prod +nano .env.prod # 填写必需变量 + +# 3. 启动服务 +chmod +x deploy/scripts/*.sh +./deploy/scripts/start.sh + +# 4. 配置 HTTPS(Let's Encrypt) +export DOMAIN=your-domain.com +export EMAIL=your-email@example.com +sudo certbot certonly --nginx -d $DOMAIN --email $EMAIL --agree-tos --non-interactive + +# 5. 更新 Nginx 配置(使用实际域名替换 nginx.conf 中的 _) +# 然后重启: docker-compose -f docker-compose.prod.yml restart nginx +``` + +## 二、常用命令 + +```bash +# 启动服务 +./deploy/scripts/start.sh + +# 停止服务 +./deploy/scripts/stop.sh + +# 更新服务 +./deploy/scripts/update.sh latest + +# 查看日志 +docker-compose -f docker-compose.prod.yml logs -f backend + +# 查看服务状态 +docker-compose -f docker-compose.prod.yml ps + +# 健康检查 +curl https://your-domain.com/api/health +``` + +## 三、GitHub Actions 自动部署 + +### 配置 Secrets + +在 GitHub 仓库设置中添加: +- `PROD_HOST`: 服务器 IP +- `PROD_USER`: SSH 用户名 +- `PROD_SSH_KEY`: SSH 私钥 +- `PROD_DOMAIN`: 生产域名 +- `PROD_APP_PATH`: 应用路径(可选) + +### 部署流程 + +1. 推送代码到 `main` 分支 +2. GitHub Actions 自动构建并部署 +3. 查看部署日志:`https://github.com/your-org/wecom-ai-assistant/actions` + +## 四、关键文件 + +| 文件 | 说明 | +|------|------| +| `.env.prod` | 生产环境变量(不提交到 Git) | +| `docker-compose.prod.yml` | 生产环境 Docker Compose 配置 | +| `deploy/docker/backend.Dockerfile` | Backend 生产镜像构建文件 | +| `deploy/docker/nginx.conf` | Nginx 生产配置 | +| `deploy/scripts/start.sh` | 启动脚本 | +| `deploy/scripts/update.sh` | 更新脚本 | +| `.github/workflows/deploy.yml` | GitHub Actions 部署 workflow | + +## 五、故障排查 + +```bash +# 查看服务日志 +docker-compose -f docker-compose.prod.yml logs backend + +# 检查服务状态 +docker-compose -f docker-compose.prod.yml ps + +# 检查环境变量 +docker-compose -f docker-compose.prod.yml config + +# 检查 SSL 证书 +sudo certbot certificates + +# 测试健康检查 +curl -v https://your-domain.com/api/health +``` + +## 六、回滚 + +```bash +# 回滚到指定版本 +export IMAGE_TAG=v0.9.0 +docker-compose -f docker-compose.prod.yml --env-file .env.prod pull +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +**详细文档**:参见 `docs/deploy.md` diff --git a/docs/deploy-quickstart.md b/docs/deploy-quickstart.md new file mode 100644 index 0000000..36d1a83 --- /dev/null +++ b/docs/deploy-quickstart.md @@ -0,0 +1,130 @@ +# 云端最小回调壳快速部署指南 + +## 一、前置条件检查清单 + +- [ ] 备案域名(例如:`api.yourdomain.com`) +- [ ] Linux 服务器(Ubuntu 20.04+ / CentOS 7+) +- [ ] 公网 IP,开放 80/443 端口 +- [ ] Docker 和 docker-compose 已安装 +- [ ] 企业微信管理后台访问权限 + +## 二、5 分钟快速部署 + +### 步骤 1:克隆项目 + +```bash +git clone https://github.com/your-org/wecom-ai-assistant.git +cd wecom-ai-assistant +``` + +### 步骤 2:配置环境变量 + +```bash +# 复制模板 +cp .env.example .env + +# 编辑 .env,填写以下必需变量: +# - WECOM_TOKEN(从企微后台获取) +# - WECOM_ENCODING_AES_KEY(从企微后台获取,43位) +# - WECOM_CORP_ID(从企微后台获取) +# - WECOM_AGENT_ID(从企微后台获取) +``` + +### 步骤 3:部署最小回调壳 + +```bash +# 方式 A:使用 docker-compose +docker-compose -f docker-compose.minimal.yml up -d + +# 方式 B:使用部署脚本 +chmod +x deploy/scripts/deploy-minimal.sh +export DOMAIN=your-domain.com +bash deploy/scripts/deploy-minimal.sh +``` + +### 步骤 4:配置 HTTPS(Let's Encrypt) + +```bash +export DOMAIN=your-domain.com +export SSL_EMAIL=your-email@example.com +chmod +x deploy/scripts/setup-ssl.sh +bash deploy/scripts/setup-ssl.sh +``` + +### 步骤 5:配置企业微信回调 + +1. 登录企业微信管理后台:https://work.weixin.qq.com +2. 进入:应用管理 → 自建应用 → 选择你的应用 +3. 配置回调: + - **接收消息服务器 URL**:`https://your-domain.com/api/wecom/callback` + - **Token**:与 `.env` 中的 `WECOM_TOKEN` **完全一致** + - **EncodingAESKey**:与 `.env` 中的 `WECOM_ENCODING_AES_KEY` **完全一致** + - **消息加解密方式**:**安全模式** +4. 点击 **保存** + +### 步骤 6:验证 + +```bash +# 查看日志 +docker-compose logs -f backend + +# 应该看到: +# INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} +``` + +## 三、关键环境变量 + +| 变量 | 说明 | 来源 | +|------|------|------| +| `WECOM_TOKEN` | 企业微信回调 Token | 企微后台 → 应用 → 接收消息 → Token | +| `WECOM_ENCODING_AES_KEY` | 43 位 Base64 编码密钥 | 企微后台 → 应用 → 接收消息 → EncodingAESKey | +| `WECOM_CORP_ID` | 企业 ID | 企微后台 → 我的企业 → 企业信息 | +| `WECOM_AGENT_ID` | 应用 AgentId | 企微后台 → 应用管理 → 自建应用 → 应用详情 | + +## 四、验证清单 + +- [ ] 服务启动:`docker-compose ps` 显示 backend 和 nginx 运行中 +- [ ] 健康检查:`curl http://localhost:8000/health` 返回 200 +- [ ] HTTPS 访问:`curl https://your-domain.com/api/health` 返回 200 +- [ ] 企业微信 GET 校验:保存回调配置后,日志显示 `wecom verify success` +- [ ] 企业微信 POST 回调:发送测试消息后,日志显示 `wecom message received` 和 `wecom reply sent` + +## 五、常见问题 + +### 5.1 GET 校验失败 + +**症状**:企微后台保存失败,日志显示 `wecom verify failed` + +**解决**: +1. 检查 `.env` 中的 `WECOM_TOKEN` 和 `WECOM_ENCODING_AES_KEY` +2. 确保与企微后台配置**完全一致**(包括大小写、空格) +3. 重启后端:`docker-compose restart backend` + +### 5.2 HTTPS 证书问题 + +**症状**:浏览器显示"不安全连接" + +**解决**: +1. 检查证书:`sudo certbot certificates` +2. 更新 Nginx 配置,使用正确的证书路径 +3. 重启 Nginx:`docker-compose restart nginx` + +### 5.3 域名无法访问 + +**症状**:`curl https://your-domain.com/api/health` 超时 + +**解决**: +1. 检查 DNS:`nslookup your-domain.com` +2. 检查防火墙:`sudo ufw status`(Ubuntu)或 `sudo firewall-cmd --list-all`(CentOS) +3. 检查端口:`netstat -tlnp | grep -E '80|443'` + +## 六、下一步 + +完成最小回调壳部署后: + +1. ✅ **最小回调壳**(当前阶段) +2. ⏭️ **数据库接入**(会话与消息入库) +3. ⏭️ **Admin 后台**(会话列表、工单、知识库) +4. ⏭️ **FAQ/RAG**(智能回复) + +**详细文档**:参见 `docs/deploy-cloud-minimal.md` diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..494e21c --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,655 @@ +# 生产环境部署指南 + +## 一、概述 + +本文档描述如何在云端服务器上部署企业微信 AI 助手的最小回调壳,支持企业微信回调 URL 校验和消息回调。 + +**部署架构**: +``` +企业微信 → HTTPS (443) → Nginx → Backend (8000) + ↓ + PostgreSQL (可选) +``` + +**服务清单**: +- `backend`: Python 3.12 + FastAPI + Uvicorn(最小回调壳) +- `nginx`: 反向代理 + HTTPS(Let's Encrypt) +- `db`: PostgreSQL 16(可选,最小回调壳可以先不启用) +- `admin`: Next.js 管理后台(可选,最小回调壳可以先不启用) + +--- + +## 二、云服务器准备 + +### 2.1 服务器要求 + +- **操作系统**:Ubuntu 20.04+ / CentOS 7+ / Debian 11+ +- **CPU**:1 核以上 +- **内存**:1GB 以上(推荐 2GB) +- **磁盘**:20GB 以上 +- **网络**:公网 IP,开放 80/443 端口 + +### 2.2 安装 Docker 和 Docker Compose + +#### Ubuntu/Debian + +```bash +# 更新系统 +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg lsb-release + +# 添加 Docker 官方 GPG 密钥 +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + +# 添加 Docker 仓库 +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# 安装 Docker +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + +# 将当前用户添加到 docker 组(可选,避免每次使用 sudo) +sudo usermod -aG docker $USER +newgrp docker + +# 验证安装 +docker --version +docker compose version +``` + +#### CentOS/RHEL + +```bash +# 安装依赖 +sudo yum install -y yum-utils + +# 添加 Docker 仓库 +sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo + +# 安装 Docker +sudo yum install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + +# 启动 Docker +sudo systemctl start docker +sudo systemctl enable docker + +# 将当前用户添加到 docker 组 +sudo usermod -aG docker $USER +newgrp docker + +# 验证安装 +docker --version +docker compose version +``` + +### 2.3 配置防火墙 + +#### Ubuntu (UFW) + +```bash +# 允许 SSH(避免锁定) +sudo ufw allow 22/tcp + +# 允许 HTTP/HTTPS +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# 启用防火墙 +sudo ufw enable + +# 检查状态 +sudo ufw status +``` + +#### CentOS/RHEL (firewalld) + +```bash +# 允许 HTTP/HTTPS +sudo firewall-cmd --permanent --add-service=http +sudo firewall-cmd --permanent --add-service=https +sudo firewall-cmd --reload + +# 检查状态 +sudo firewall-cmd --list-all +``` + +--- + +## 三、域名 DNS 配置 + +### 3.1 添加 A 记录 + +在域名服务商(如阿里云、腾讯云、Cloudflare)添加 A 记录: + +``` +类型: A +主机记录: @ 或 api(例如:api.yourdomain.com) +记录值: 你的服务器公网 IP +TTL: 600(或默认) +``` + +### 3.2 验证 DNS 解析 + +```bash +# 检查 DNS 解析 +nslookup your-domain.com +# 或 +dig your-domain.com + +# 应该返回你的服务器 IP +``` + +--- + +## 四、项目部署 + +### 4.1 克隆项目 + +```bash +# 创建项目目录 +sudo mkdir -p /opt/wecom-ai-assistant +sudo chown $USER:$USER /opt/wecom-ai-assistant +cd /opt/wecom-ai-assistant + +# 克隆项目(或通过 CI/CD 部署) +git clone https://github.com/your-org/wecom-ai-assistant.git . +``` + +### 4.2 配置环境变量 + +```bash +# 复制环境变量模板 +cp .env.example .env.prod + +# 编辑 .env.prod,填写必需变量 +nano .env.prod +``` + +**必需变量**: + +```bash +# ============ Backend ============ +API_HOST=0.0.0.0 +API_PORT=8000 + +# Database(可选,最小回调壳可以先不启用) +# DATABASE_URL=postgresql+asyncpg://wecom:wecom_secret@db:5432/wecom_ai +# DATABASE_URL_SYNC=postgresql://wecom:wecom_secret@db:5432/wecom_ai + +# JWT(admin 登录,可选) +JWT_SECRET=your-jwt-secret-change-in-production +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=60 + +# WeCom Callback(必须,从企业微信管理后台获取) +WECOM_CORP_ID=你的企业ID +WECOM_AGENT_ID=你的应用AgentId +WECOM_SECRET=你的应用Secret(可选) +WECOM_TOKEN=你的Token(必须与企微后台一致) +WECOM_ENCODING_AES_KEY=你的43位密钥(必须与企微后台一致) + +# WeCom API +WECOM_API_BASE=https://qyapi.weixin.qq.com +WECOM_API_TIMEOUT=10 +WECOM_API_RETRIES=2 + +# Log +LOG_LEVEL=INFO +LOG_JSON=true +``` + +**重要**: +- `WECOM_TOKEN` 和 `WECOM_ENCODING_AES_KEY` 必须与企微后台配置**完全一致** +- `.env.prod` 文件包含敏感信息,**不要提交到 Git 仓库** + +### 4.3 首次部署 + +#### 方式 A:使用部署脚本(推荐) + +```bash +# 赋予执行权限 +chmod +x deploy/scripts/*.sh + +# 启动服务 +./deploy/scripts/start.sh +``` + +#### 方式 B:手动部署 + +```bash +# 设置镜像标签(默认 latest) +export IMAGE_TAG=latest +export GITHUB_REPOSITORY_OWNER=your-org + +# 启动服务 +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d + +# 查看日志 +docker-compose -f docker-compose.prod.yml logs -f backend +``` + +--- + +## 五、HTTPS 配置(Let's Encrypt) + +### 5.1 安装 Certbot + +#### Ubuntu/Debian + +```bash +sudo apt-get update +sudo apt-get install -y certbot python3-certbot-nginx +``` + +#### CentOS/RHEL + +```bash +sudo yum install -y certbot python3-certbot-nginx +``` + +### 5.2 配置 Nginx 占位路径 + +在获取证书之前,需要先配置 Nginx 的 HTTP 服务,以便 Certbot 验证域名所有权。 + +**临时修改 `deploy/docker/nginx.conf`**: + +```nginx +# HTTP 服务器(临时配置,用于证书申请) +server { + listen 80; + server_name your-domain.com; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # /api -> backend + location /api/ { + proxy_pass http://backend; + 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 /health { + proxy_pass http://backend/api/health; + access_log off; + } +} +``` + +**创建验证目录**: + +```bash +sudo mkdir -p /var/www/certbot +sudo chown -R $USER:$USER /var/www/certbot +``` + +**更新 `docker-compose.prod.yml`**,添加验证目录挂载: + +```yaml +nginx: + volumes: + - ./deploy/docker/nginx.conf:/etc/nginx/nginx.conf:ro + - /var/www/certbot:/var/www/certbot:ro # 添加此行 +``` + +**重启 Nginx**: + +```bash +docker-compose -f docker-compose.prod.yml restart nginx +``` + +### 5.3 申请 SSL 证书 + +```bash +# 设置域名和邮箱 +export DOMAIN=your-domain.com +export EMAIL=your-email@example.com + +# 申请证书(使用 Nginx 插件) +sudo certbot certonly --nginx \ + -d $DOMAIN \ + -d www.$DOMAIN \ + --email $EMAIL \ + --agree-tos \ + --non-interactive \ + --preferred-challenges http + +# 或使用 standalone 模式(如果 Nginx 未运行) +sudo certbot certonly --standalone \ + -d $DOMAIN \ + -d www.$DOMAIN \ + --email $EMAIL \ + --agree-tos \ + --non-interactive +``` + +### 5.4 更新 Nginx 配置 + +证书申请成功后,更新 `deploy/docker/nginx.conf`: + +```nginx +# HTTPS 服务器 +server { + listen 443 ssl http2; + server_name your-domain.com; + + # SSL 证书(使用实际域名替换 _) + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + # ... 其他配置 +} +``` + +**更新 `docker-compose.prod.yml`**,确保证书目录已挂载: + +```yaml +nginx: + volumes: + - ./deploy/docker/nginx.conf:/etc/nginx/nginx.conf:ro + - /etc/letsencrypt:/etc/letsencrypt:ro # SSL 证书 + - /var/www/certbot:/var/www/certbot:ro # 验证文件 +``` + +**重启 Nginx**: + +```bash +docker-compose -f docker-compose.prod.yml restart nginx +``` + +### 5.5 配置证书自动续期 + +Let's Encrypt 证书有效期为 90 天,需要定期续期。 + +```bash +# 测试续期 +sudo certbot renew --dry-run + +# 添加定时任务(每天检查一次) +sudo crontab -e + +# 添加以下行: +0 0 * * * certbot renew --quiet --deploy-hook "docker-compose -f /opt/wecom-ai-assistant/docker-compose.prod.yml restart nginx" +``` + +--- + +## 六、部署命令 + +### 6.1 首次部署 + +```bash +# 使用部署脚本 +./deploy/scripts/start.sh + +# 或手动部署 +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +### 6.2 更新部署 + +#### 方式 A:使用部署脚本 + +```bash +# 更新到最新版本 +./deploy/scripts/update.sh latest + +# 更新到指定版本 +./deploy/scripts/update.sh v1.0.0 +``` + +#### 方式 B:手动更新 + +```bash +# 设置镜像标签 +export IMAGE_TAG=latest # 或指定版本,如 v1.0.0 + +# 登录到容器镜像仓库 +docker login ghcr.io -u your-username -p your-token + +# 拉取最新镜像 +docker-compose -f docker-compose.prod.yml --env-file .env.prod pull + +# 重启服务 +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +### 6.3 回滚部署 + +```bash +# 回滚到指定版本(需要知道之前的镜像标签) +export IMAGE_TAG=v0.9.0 # 替换为要回滚的版本 + +# 拉取指定版本镜像 +docker-compose -f docker-compose.prod.yml --env-file .env.prod pull + +# 重启服务 +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +**查看历史版本**: + +```bash +# 查看 GitHub Container Registry 中的镜像标签 +# 访问: https://github.com/your-org/wecom-ai-assistant/pkgs/container/wecom-ai-backend +``` + +### 6.4 停止服务 + +```bash +# 使用部署脚本 +./deploy/scripts/stop.sh + +# 或手动停止 +docker-compose -f docker-compose.prod.yml --env-file .env.prod down +``` + +--- + +## 七、验证部署 + +### 7.1 检查服务状态 + +```bash +# 查看服务状态 +docker-compose -f docker-compose.prod.yml ps + +# 查看日志 +docker-compose -f docker-compose.prod.yml logs -f backend +``` + +### 7.2 健康检查 + +```bash +# HTTP 健康检查(如果 HTTPS 未配置) +curl http://your-domain.com/api/health + +# HTTPS 健康检查(推荐) +curl https://your-domain.com/api/health + +# 应该返回: +# {"status":"ok"} +``` + +### 7.3 企业微信回调验证 + +1. **配置企业微信回调 URL**: + - 登录企业微信管理后台:https://work.weixin.qq.com + - 进入:应用管理 → 自建应用 → 选择你的应用 + - 配置回调: + - **接收消息服务器 URL**:`https://your-domain.com/api/wecom/callback` + - **Token**:与 `.env.prod` 中的 `WECOM_TOKEN` **完全一致** + - **EncodingAESKey**:与 `.env.prod` 中的 `WECOM_ENCODING_AES_KEY` **完全一致** + - **消息加解密方式**:**安全模式** + - 点击 **保存** + +2. **验证 GET 校验**: + - 保存后,企业微信会立即发送 GET 请求验证 + - 观察后端日志: + ```bash + docker-compose -f docker-compose.prod.yml logs -f backend + ``` + - 应该看到: + ``` + INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} + ``` + +3. **验证 POST 回调**: + - 在企业微信中发送测试消息 + - 观察后端日志,应该看到: + ``` + INFO: wecom message received {"trace_id": "...", "external_userid": "...", "msgid": "...", "content_summary": "..."} + INFO: wecom reply sent {"trace_id": "...", "reply_summary": "..."} + ``` + +--- + +## 八、GitHub Actions 自动部署 + +### 8.1 配置 GitHub Secrets + +在 GitHub 仓库设置中添加以下 Secrets: + +| Secret 名称 | 说明 | 示例 | +|------------|------|------| +| `PROD_HOST` | 生产服务器 IP 或域名 | `123.45.67.89` | +| `PROD_USER` | SSH 用户名 | `ubuntu` | +| `PROD_SSH_KEY` | SSH 私钥 | `-----BEGIN OPENSSH PRIVATE KEY-----...` | +| `PROD_SSH_PORT` | SSH 端口(可选,默认 22) | `22` | +| `PROD_DOMAIN` | 生产域名 | `api.yourdomain.com` | +| `PROD_APP_PATH` | 应用部署路径(可选) | `/opt/wecom-ai-assistant` | + +### 8.2 生成 SSH 密钥对 + +```bash +# 在本地生成 SSH 密钥对 +ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github-actions-deploy + +# 将公钥添加到服务器 +ssh-copy-id -i ~/.ssh/github-actions-deploy.pub user@your-server + +# 将私钥内容复制到 GitHub Secrets +cat ~/.ssh/github-actions-deploy +``` + +### 8.3 自动部署流程 + +1. **推送代码到 main 分支**: + ```bash + git push origin main + ``` + +2. **GitHub Actions 自动执行**: + - 构建 backend 镜像 + - 推送到 GHCR + - SSH 到生产服务器 + - 拉取最新镜像 + - 重启服务 + - 健康检查 + +3. **查看部署日志**: + - 访问 GitHub Actions:`https://github.com/your-org/wecom-ai-assistant/actions` + +--- + +## 九、常见问题 + +### 9.1 服务启动失败 + +**症状**:`docker-compose ps` 显示服务状态为 `Restarting` 或 `Exited` + +**排查**: +```bash +# 查看详细日志 +docker-compose -f docker-compose.prod.yml logs backend + +# 检查环境变量 +docker-compose -f docker-compose.prod.yml config +``` + +**常见原因**: +- 环境变量未正确配置 +- 端口被占用 +- 镜像拉取失败 + +### 9.2 HTTPS 证书问题 + +**症状**:浏览器显示"不安全连接"或证书错误 + +**排查**: +```bash +# 检查证书 +sudo certbot certificates + +# 检查证书有效期 +sudo openssl x509 -in /etc/letsencrypt/live/your-domain.com/cert.pem -noout -dates + +# 测试续期 +sudo certbot renew --dry-run +``` + +### 9.3 企业微信回调失败 + +**症状**:企微后台保存失败,日志显示 `wecom verify failed` + +**排查**: +1. 检查 `.env.prod` 中的 `WECOM_TOKEN` 和 `WECOM_ENCODING_AES_KEY` +2. 确保与企微后台配置**完全一致**(包括大小写、空格) +3. 检查域名是否可访问:`curl https://your-domain.com/api/wecom/callback` +4. 查看后端日志:`docker-compose -f docker-compose.prod.yml logs backend` + +### 9.4 镜像拉取失败 + +**症状**:`docker pull` 失败,提示认证错误 + +**解决**: +```bash +# 登录到 GHCR +docker login ghcr.io -u your-username -p your-token + +# 或使用 GitHub Personal Access Token +echo $GITHUB_TOKEN | docker login ghcr.io -u your-username --password-stdin +``` + +--- + +## 十、安全最佳实践 + +1. **环境变量**: + - 使用 `.env.prod` 存储敏感信息 + - **不要提交 `.env.prod` 到 Git 仓库** + - 定期轮换密钥和 Token + +2. **SSH 安全**: + - 使用密钥认证,禁用密码登录 + - 限制 SSH 访问 IP(可选) + - 定期更新 SSH 密钥 + +3. **防火墙**: + - 只开放必要的端口(80, 443, 22) + - 使用 fail2ban 防止暴力破解(可选) + +4. **日志**: + - 定期清理日志文件 + - 监控异常日志 + +5. **更新**: + - 定期更新系统和 Docker + - 及时应用安全补丁 + +--- + +## 十一、参考文档 + +- [本地部署指南](./deploy-cloud-minimal.md) +- [企业微信回调配置](./wecom.md) +- [企业微信测试指南](./wecom-test-guide.md) +- [GitHub Actions 部署 Workflow](../.github/workflows/deploy.yml) diff --git a/docs/docker-mirror.md b/docs/docker-mirror.md new file mode 100644 index 0000000..54e89e7 --- /dev/null +++ b/docs/docker-mirror.md @@ -0,0 +1,62 @@ +# Docker 拉取失败:配置镜像加速 + +构建时若出现 `failed to fetch oauth token`、`dial tcp ... connectex` 等无法连接 `auth.docker.io` 的错误,多半是网络无法访问 Docker Hub。 + +## 本仓库已做的默认处理 + +- **admin** 与 **backend** 的 Dockerfile 已默认使用国内镜像源拉取基础镜像:`docker.mirrors.ustc.edu.cn/library/node:20-alpine`、`docker.mirrors.ustc.edu.cn/library/python:3.12-slim`。多数情况下可直接执行 `docker-compose up -d` 构建。 +- 若你环境能直连 Docker Hub、希望用官方镜像,可执行: + ```bash + docker-compose build --build-arg NODE_IMAGE=node:20-alpine --build-arg PYTHON_IMAGE=python:3.12-slim + docker-compose up -d + ``` + +若仍报错(例如 USTC 镜像在你网络不可用),可再按下面方式配置 Docker daemon 的镜像加速。 + +## 方式一:Docker Desktop(推荐) + +1. 打开 **Docker Desktop** → **Settings**(设置)→ **Docker Engine**。 +2. 在 JSON 里为 `"registry-mirrors"` 增加一个镜像地址,例如: + ```json + { + "registry-mirrors": [ + "https://docker.mirrors.ustc.edu.cn", + "https://hub-mirror.c.163.com" + ] + } + ``` +3. 点击 **Apply & Restart**,等待重启完成。 +4. 回到项目根目录重新执行:`docker-compose up -d`。 + +## 方式二:Linux 直接改 daemon 配置 + +编辑 `/etc/docker/daemon.json`(没有则新建),加入: + +```json +{ + "registry-mirrors": [ + "https://docker.mirrors.ustc.edu.cn", + "https://hub-mirror.c.163.com" + ] +} +``` + +然后执行: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart docker +``` + +## 镜像源说明 + +- 上面为常见公共镜像,可用性以当前网络为准。 +- 若使用阿里云 ECS,可在容器镜像服务里申请**专属加速地址**,替换到 `registry-mirrors` 中,通常更稳定。 + +配置完成后再次运行: + +```powershell +docker-compose up -d +``` + +若仍失败,可尝试开代理或换网络环境后再试。 diff --git a/docs/github-config-guide.md b/docs/github-config-guide.md new file mode 100644 index 0000000..20a8e0c --- /dev/null +++ b/docs/github-config-guide.md @@ -0,0 +1,168 @@ +# GitHub 配置文件使用指南 + +## 概述 + +项目已包含 GitHub 配置文件(`.github-config`),用于自动化 GitHub 操作。配置文件包含你的 GitHub 用户名、token 和仓库信息。 + +## 配置文件说明 + +### 文件位置 + +- **`.github-config`**:实际配置文件(包含敏感信息,**不提交到 Git**) +- **`.github-config.example`**:配置模板(可提交到 Git) + +### 配置项说明 + +```bash +# GitHub 用户名/组织 +GITHUB_USERNAME=bujie9527 + +# GitHub Personal Access Token +# ⚠️ 警告:此 token 具有仓库访问权限,请妥善保管! +GITHUB_TOKEN=your_token_here + +# GitHub 仓库名称 +GITHUB_REPO_NAME=wecom-ai-assistant + +# GitHub 仓库完整 URL +GITHUB_REPO_URL=https://github.com/bujie9527/wecom-ai-assistant.git + +# 默认分支 +GITHUB_DEFAULT_BRANCH=main + +# Container Registry 配置 +GHCR_REGISTRY=ghcr.io +GHCR_IMAGE_PREFIX=bujie9527 + +# 镜像名称 +BACKEND_IMAGE_NAME=wecom-ai-backend +ADMIN_IMAGE_NAME=wecom-ai-admin +``` + +## 快速使用 + +### 1. 初始化 GitHub 仓库 + +```powershell +# 使用配置文件自动设置 Git 远程仓库 +.\scripts\setup-github-from-config.ps1 +``` + +此脚本会: +- 检查并初始化 Git 仓库 +- 配置 Git 用户信息(如果需要) +- 设置远程仓库地址 +- 配置 Git 凭据(使用 token) +- 创建初始提交(如果还没有) + +### 2. 推送代码到 GitHub + +```powershell +# 快速推送(使用默认提交信息) +.\scripts\push-to-github.ps1 + +# 自定义提交信息 +.\scripts\push-to-github.ps1 -CommitMessage "Fix: 修复登录问题" + +# 强制推送(危险操作) +.\scripts\push-to-github.ps1 -Force +``` + +### 3. 手动操作 + +如果不想使用脚本,也可以手动操作: + +```powershell +# 添加远程仓库 +git remote add origin https://github.com/bujie9527/wecom-ai-assistant.git + +# 使用 token 推送(将 token 嵌入 URL) +git remote set-url origin https://bujie9527:YOUR_TOKEN@github.com/bujie9527/wecom-ai-assistant.git + +# 推送代码 +git push -u origin main +``` + +## 安全注意事项 + +### ⚠️ 重要安全提示 + +1. **`.github-config` 文件已添加到 `.gitignore`**,不会被提交到 Git +2. **Token 安全**: + - 不要将 token 分享给他人 + - 不要在公开场合展示 token + - 如果 token 泄露,立即在 GitHub 上撤销并重新生成 +3. **Token 权限**: + - 当前 token 需要以下权限: + - `repo`:访问和修改仓库 + - `write:packages`:推送 Docker 镜像到 GHCR + - `read:packages`:从 GHCR 拉取镜像 + +### 撤销和重新生成 Token + +如果 token 泄露或需要更新: + +1. 访问:https://github.com/settings/tokens +2. 找到对应的 token,点击 **Revoke**(撤销) +3. 点击 **Generate new token**(生成新 token) +4. 选择权限:`repo`, `write:packages`, `read:packages` +5. 复制新 token 并更新 `.github-config` 文件 + +## 配置文件的使用场景 + +### 1. 本地开发 + +配置文件主要用于: +- 自动配置 Git 远程仓库 +- 快速推送代码 +- 本地脚本自动化操作 + +### 2. GitHub Actions + +GitHub Actions workflow 使用 GitHub Secrets,而不是配置文件: +- 配置文件中的 token 用于**本地推送** +- GitHub Actions 使用 `GITHUB_TOKEN`(自动提供)或 `GHCR_TOKEN`(在 Secrets 中配置) + +### 3. 生产部署 + +生产服务器上: +- 使用 SSH 密钥(不是 token)进行部署 +- 通过 GitHub Actions 自动部署 +- 不需要在生产服务器上配置 token + +## 常见问题 + +### Q: 配置文件在哪里? + +**A**: 配置文件位于项目根目录: +- `.github-config`:实际配置文件(本地使用,不提交) +- `.github-config.example`:模板文件(可提交) + +### Q: 如何更新配置? + +**A**: 直接编辑 `.github-config` 文件,修改对应的值即可。 + +### Q: Token 在哪里获取? + +**A**: +1. 访问:https://github.com/settings/tokens +2. 点击 **Generate new token (classic)** +3. 选择权限:`repo`, `write:packages`, `read:packages` +4. 复制 token 并更新配置文件 + +### Q: 推送时提示认证失败? + +**A**: +1. 检查 token 是否正确 +2. 确认 token 权限包含 `repo` +3. 尝试重新运行 `.\scripts\setup-github-from-config.ps1` + +### Q: 配置文件可以提交到 Git 吗? + +**A**: **不可以**。`.github-config` 包含敏感 token,已添加到 `.gitignore`。只有 `.github-config.example`(模板)可以提交。 + +## 相关文档 + +- [GitHub 快速开始指南](./github-quickstart.md) +- [GitHub 完整设置指南](./github-setup-guide.md) +- [生产部署文档](./deploy.md) diff --git a/docs/github-quickstart.md b/docs/github-quickstart.md new file mode 100644 index 0000000..22c68c4 --- /dev/null +++ b/docs/github-quickstart.md @@ -0,0 +1,235 @@ +# GitHub Actions 快速开始指南 + +## 5 分钟快速部署 + +### 步骤 1:创建 GitHub 仓库(2 分钟) + +1. 访问 https://github.com/new +2. 填写仓库名称:`wecom-ai-assistant` +3. 选择 **Private**(推荐) +4. 点击 **Create repository** + +### 步骤 2:推送代码到 GitHub(1 分钟) + +在项目根目录执行: + +```powershell +# 初始化 Git(如果还没有) +git init + +# 添加所有文件 +git add . + +# 提交 +git commit -m "Initial commit: 企业微信 AI 助手" + +# 添加远程仓库(替换 YOUR_USERNAME) +git remote add origin https://github.com/YOUR_USERNAME/wecom-ai-assistant.git + +# 推送到 GitHub +git branch -M main +git push -u origin main +``` + +**如果遇到认证问题**: +- 访问 https://github.com/settings/tokens +- 生成新 token(权限:`repo`, `write:packages`) +- 使用 token 作为密码推送 + +### 步骤 3:配置 GitHub Secrets(2 分钟) + +1. 进入仓库 → **Settings** → **Secrets and variables** → **Actions** +2. 点击 **New repository secret**,依次添加: + +#### 必需 Secrets + +| Secret 名称 | 说明 | 如何获取 | +|------------|------|---------| +| `PROD_HOST` | 服务器 IP | 你的云服务器公网 IP | +| `PROD_USER` | SSH 用户名 | 通常是 `root` 或 `ubuntu` | +| `PROD_SSH_KEY` | SSH 私钥 | 见下方"生成 SSH 密钥" | +| `PROD_DOMAIN` | 生产域名 | 例如:`api.yourdomain.com` | + +#### 可选 Secrets + +| Secret 名称 | 说明 | 默认值 | +|------------|------|--------| +| `PROD_SSH_PORT` | SSH 端口 | `22` | +| `PROD_APP_PATH` | 应用路径 | `/opt/wecom-ai-assistant` | +| `GHCR_TOKEN` | GitHub Packages Token | 使用默认 `GITHUB_TOKEN` | + +#### 生成 SSH 密钥 + +```powershell +# 在本地生成 SSH 密钥对 +ssh-keygen -t ed25519 -C "github-actions" -f $env:USERPROFILE\.ssh\github-actions + +# 查看私钥(复制到 GitHub Secrets 的 PROD_SSH_KEY) +cat $env:USERPROFILE\.ssh\github-actions + +# 查看公钥(需要添加到服务器) +cat $env:USERPROFILE\.ssh\github-actions.pub +``` + +**将公钥添加到服务器**: + +```bash +# SSH 登录服务器 +ssh user@your-server + +# 添加公钥到 authorized_keys +mkdir -p ~/.ssh +echo "你的公钥内容" >> ~/.ssh/authorized_keys +chmod 600 ~/.ssh/authorized_keys +chmod 700 ~/.ssh +``` + +### 步骤 4:配置仓库权限(30 秒) + +1. 进入仓库 → **Settings** → **Actions** → **General** +2. 找到 **Workflow permissions** +3. 选择 **Read and write permissions** +4. 点击 **Save** + +### 步骤 5:在生产服务器上准备(5 分钟) + +```bash +# 1. SSH 登录服务器 +ssh user@your-server + +# 2. 安装 Docker(Ubuntu/Debian) +sudo apt-get update +sudo apt-get install -y docker.io docker-compose-plugin +sudo systemctl start docker +sudo systemctl enable docker +sudo usermod -aG docker $USER +newgrp docker + +# 3. 创建项目目录 +sudo mkdir -p /opt/wecom-ai-assistant +sudo chown $USER:$USER /opt/wecom-ai-assistant +cd /opt/wecom-ai-assistant + +# 4. 克隆项目 +git clone https://github.com/YOUR_USERNAME/wecom-ai-assistant.git . + +# 5. 创建生产环境变量文件 +cp .env.prod.example .env.prod +nano .env.prod # 填写实际配置 +``` + +**必需的环境变量**(`.env.prod`): + +```bash +# WeCom 回调配置(必须) +WECOM_CORP_ID=你的企业ID +WECOM_AGENT_ID=你的应用AgentId +WECOM_TOKEN=你的Token(43位随机字符串) +WECOM_ENCODING_AES_KEY=你的43位密钥 + +# 其他配置... +``` + +### 步骤 6:触发首次部署(1 分钟) + +#### 方式 A:推送代码自动触发 + +```powershell +# 在本地执行 +git add . +git commit -m "Trigger deployment" +git push origin main +``` + +#### 方式 B:手动触发 + +1. 进入 GitHub 仓库 → **Actions** +2. 选择 **Deploy to Production** workflow +3. 点击 **Run workflow** +4. 选择分支 `main`,点击 **Run workflow** + +### 步骤 7:验证部署(1 分钟) + +1. **查看 GitHub Actions 日志**: + - 进入 **Actions** → 点击最新的 workflow run + - 查看各步骤执行情况 + +2. **验证服务状态**: + ```bash + # SSH 到服务器 + ssh user@your-server + cd /opt/wecom-ai-assistant + + # 查看服务状态 + docker compose -f docker-compose.prod.yml ps + + # 查看日志 + docker compose -f docker-compose.prod.yml logs -f backend + ``` + +3. **健康检查**: + ```bash + curl https://your-domain.com/api/health + ``` + +--- + +## 后续更新部署 + +### 自动部署(推送到 main) + +```powershell +git add . +git commit -m "Update: 描述你的更改" +git push origin main +``` + +推送后,GitHub Actions 会自动: +1. 构建 backend 镜像 +2. 推送到 GHCR +3. SSH 到服务器部署 +4. 执行健康检查 + +### 手动触发部署 + +1. 进入 **Actions** → **Deploy to Production** +2. 点击 **Run workflow** +3. 可选择指定镜像标签(用于回滚) + +--- + +## 常见问题 + +### Q: SSH 连接失败? + +**A**: 检查以下项: +- `PROD_HOST`、`PROD_USER`、`PROD_SSH_KEY` 是否正确 +- 服务器防火墙是否开放 SSH 端口 +- SSH 公钥是否已添加到服务器的 `~/.ssh/authorized_keys` + +### Q: 镜像推送失败? + +**A**: +- 确认仓库权限已设置为 "Read and write" +- 检查 GitHub Packages 是否启用 + +### Q: 部署后服务无法访问? + +**A**: +- 检查 `.env.prod` 配置是否正确 +- 查看服务日志:`docker compose -f docker-compose.prod.yml logs backend` +- 确认域名 DNS 已正确解析到服务器 IP + +### Q: 如何回滚到之前的版本? + +**A**: +1. 在 GitHub Actions 中手动触发 +2. 指定镜像标签(例如:`main-abc1234`,commit SHA 的前缀) + +--- + +## 下一步 + +- 配置 HTTPS:参见 `docs/deploy.md` 中的 Let's Encrypt 部分 +- 配置企业微信回调:参见 `docs/wecom.md` +- 详细部署文档:参见 `docs/github-setup-guide.md` diff --git a/docs/github-setup-guide.md b/docs/github-setup-guide.md new file mode 100644 index 0000000..d6fadbd --- /dev/null +++ b/docs/github-setup-guide.md @@ -0,0 +1,350 @@ +# GitHub 项目设置与部署 Workflow 配置指南 + +## 一、创建 GitHub 仓库 + +### 1.1 在 GitHub 上创建新仓库 + +1. 登录 GitHub:https://github.com +2. 点击右上角 **+** → **New repository** +3. 填写仓库信息: + - **Repository name**: `wecom-ai-assistant`(或你喜欢的名称) + - **Description**: 企业微信 AI 机器人助理 + - **Visibility**: Private(推荐)或 Public + - **不要**勾选 "Initialize this repository with a README" +4. 点击 **Create repository** + +### 1.2 推送本地代码到 GitHub + +```powershell +# 在项目根目录执行 +cd D:\企微AI助手 + +# 初始化 Git(如果还没有) +git init + +# 添加所有文件 +git add . + +# 提交 +git commit -m "Initial commit: 企业微信 AI 助手 MVP" + +# 添加远程仓库(替换 YOUR_USERNAME 为你的 GitHub 用户名) +git remote add origin https://github.com/YOUR_USERNAME/wecom-ai-assistant.git + +# 推送到 GitHub +git branch -M main +git push -u origin main +``` + +**注意**:如果遇到认证问题,可能需要配置 GitHub Personal Access Token: +- 访问:https://github.com/settings/tokens +- 生成新 token(权限:`repo`, `write:packages`, `read:packages`) +- 使用 token 作为密码推送 + +--- + +## 二、配置 GitHub Secrets + +### 2.1 进入仓库设置 + +1. 在 GitHub 仓库页面,点击 **Settings** +2. 左侧菜单选择 **Secrets and variables** → **Actions** +3. 点击 **New repository secret** + +### 2.2 添加必需的 Secrets + +依次添加以下 Secrets: + +| Secret 名称 | 说明 | 示例值 | +|------------|------|--------| +| `PROD_HOST` | 生产服务器 IP 或域名 | `123.45.67.89` | +| `PROD_USER` | SSH 用户名 | `ubuntu` 或 `root` | +| `PROD_SSH_KEY` | SSH 私钥(完整内容) | `-----BEGIN OPENSSH PRIVATE KEY-----...` | +| `PROD_SSH_PORT` | SSH 端口(可选,默认 22) | `22` | +| `PROD_DOMAIN` | 生产域名(用于健康检查) | `api.yourdomain.com` | +| `PROD_APP_PATH` | 应用部署路径(可选) | `/opt/wecom-ai-assistant` | + +### 2.3 生成 SSH 密钥对 + +在本地生成 SSH 密钥对(如果还没有): + +```powershell +# 生成 SSH 密钥对 +ssh-keygen -t ed25519 -C "github-actions-deploy" -f $env:USERPROFILE\.ssh\github-actions-deploy + +# 查看公钥(需要添加到服务器) +cat $env:USERPROFILE\.ssh\github-actions-deploy.pub + +# 查看私钥(需要添加到 GitHub Secrets) +cat $env:USERPROFILE\.ssh\github-actions-deploy +``` + +**重要**: +- **公钥**(`.pub` 文件)需要添加到生产服务器的 `~/.ssh/authorized_keys` +- **私钥**(无 `.pub` 后缀)需要添加到 GitHub Secrets 的 `PROD_SSH_KEY` + +### 2.4 将公钥添加到生产服务器 + +```bash +# 在本地执行(将公钥复制到服务器) +type $env:USERPROFILE\.ssh\github-actions-deploy.pub | ssh user@your-server "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys" + +# 或在服务器上手动添加 +# 1. SSH 登录服务器 +# 2. 编辑 ~/.ssh/authorized_keys +# 3. 粘贴公钥内容 +``` + +--- + +## 三、配置 GitHub Packages 权限 + +### 3.1 启用 GitHub Packages + +GitHub Actions 默认使用 `GITHUB_TOKEN` 推送镜像到 GHCR,但需要确保仓库有写入权限: + +1. 进入仓库 **Settings** → **Actions** → **General** +2. 找到 **Workflow permissions** +3. 选择 **Read and write permissions** +4. 勾选 **Allow GitHub Actions to create and approve pull requests**(可选) +5. 点击 **Save** + +### 3.2 验证镜像推送权限 + +首次推送后,检查镜像是否成功推送到 GHCR: +- 访问:`https://github.com/YOUR_USERNAME/wecom-ai-assistant/pkgs/container/wecom-ai-backend` + +--- + +## 四、首次部署流程 + +### 4.1 在生产服务器上准备 + +```bash +# 1. SSH 登录生产服务器 +ssh user@your-server + +# 2. 安装 Docker 和 Docker Compose(如果还没有) +# Ubuntu/Debian: +sudo apt-get update +sudo apt-get install -y docker.io docker-compose-plugin + +# CentOS/RHEL: +sudo yum install -y docker docker-compose-plugin + +# 3. 启动 Docker +sudo systemctl start docker +sudo systemctl enable docker + +# 4. 将当前用户添加到 docker 组(避免每次使用 sudo) +sudo usermod -aG docker $USER +newgrp docker + +# 5. 创建项目目录 +sudo mkdir -p /opt/wecom-ai-assistant +sudo chown $USER:$USER /opt/wecom-ai-assistant +cd /opt/wecom-ai-assistant + +# 6. 克隆项目 +git clone https://github.com/YOUR_USERNAME/wecom-ai-assistant.git . + +# 7. 创建生产环境变量文件 +cp .env.prod.example .env.prod +nano .env.prod # 填写实际的生产环境变量 +``` + +### 4.2 配置生产环境变量 + +编辑 `.env.prod`,填写以下必需变量: + +```bash +# WeCom Callback(必须) +WECOM_CORP_ID=你的企业ID +WECOM_AGENT_ID=你的应用AgentId +WECOM_TOKEN=你的Token(必须与企微后台一致) +WECOM_ENCODING_AES_KEY=你的43位密钥(必须与企微后台一致) + +# 其他配置... +``` + +### 4.3 配置 HTTPS(Let's Encrypt) + +```bash +# 1. 安装 Certbot +sudo apt-get install -y certbot python3-certbot-nginx + +# 2. 先启动 HTTP 服务(用于验证) +export IMAGE_TAG=latest +export GITHUB_REPOSITORY_OWNER=YOUR_USERNAME +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d backend nginx + +# 3. 申请 SSL 证书 +export DOMAIN=your-domain.com +export EMAIL=your-email@example.com +sudo certbot certonly --nginx -d $DOMAIN --email $EMAIL --agree-tos --non-interactive + +# 4. 更新 Nginx 配置(使用实际域名替换 nginx.conf 中的 _) +# 编辑 deploy/docker/nginx.conf,将 server_name _ 改为 server_name your-domain.com +# 将 SSL 证书路径改为实际路径 + +# 5. 重启 Nginx +docker-compose -f docker-compose.prod.yml restart nginx +``` + +### 4.4 触发首次部署 + +有两种方式: + +#### 方式 A:通过 GitHub Actions(推荐) + +1. 在 GitHub 仓库页面,点击 **Actions** 标签 +2. 选择 **Deploy to Production** workflow +3. 点击 **Run workflow** +4. 选择分支(`main`)和镜像标签(默认 `latest`) +5. 点击 **Run workflow** + +#### 方式 B:手动推送代码 + +```powershell +# 在本地执行 +git add . +git commit -m "Trigger deployment" +git push origin main +``` + +推送后,GitHub Actions 会自动: +1. 构建 backend 镜像 +2. 推送到 GHCR +3. SSH 到生产服务器 +4. 拉取镜像并部署 +5. 执行健康检查 + +--- + +## 五、验证部署 + +### 5.1 查看 GitHub Actions 日志 + +1. 进入 GitHub 仓库 → **Actions** +2. 点击最新的 workflow run +3. 查看各步骤的执行日志 + +### 5.2 验证服务状态 + +```bash +# SSH 到生产服务器 +ssh user@your-server +cd /opt/wecom-ai-assistant + +# 查看服务状态 +docker-compose -f docker-compose.prod.yml ps + +# 查看日志 +docker-compose -f docker-compose.prod.yml logs -f backend + +# 健康检查 +curl https://your-domain.com/api/health +``` + +### 5.3 配置企业微信回调 + +1. 登录企业微信管理后台:https://work.weixin.qq.com +2. 进入:应用管理 → 自建应用 → 选择你的应用 +3. 配置回调: + - **接收消息服务器 URL**:`https://your-domain.com/api/wecom/callback` + - **Token**:与 `.env.prod` 中的 `WECOM_TOKEN` **完全一致** + - **EncodingAESKey**:与 `.env.prod` 中的 `WECOM_ENCODING_AES_KEY` **完全一致** + - **消息加解密方式**:**安全模式** +4. 点击 **保存** + +--- + +## 六、后续更新部署 + +### 6.1 自动部署(推送到 main) + +```powershell +# 本地修改代码后 +git add . +git commit -m "Update: 描述你的更改" +git push origin main +``` + +推送后,GitHub Actions 会自动触发部署。 + +### 6.2 手动触发部署 + +1. 进入 GitHub 仓库 → **Actions** +2. 选择 **Deploy to Production** workflow +3. 点击 **Run workflow** +4. 可选择指定镜像标签(用于回滚) + +### 6.3 回滚到指定版本 + +```bash +# 在 GitHub Actions 中手动触发,指定镜像标签 +# 例如:main-abc1234(commit SHA 的前缀) + +# 或在服务器上手动回滚 +cd /opt/wecom-ai-assistant +export IMAGE_TAG=main-abc1234 +docker-compose -f docker-compose.prod.yml --env-file .env.prod pull +docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +--- + +## 七、故障排查 + +### 7.1 GitHub Actions 失败 + +**常见问题**: + +1. **SSH 连接失败**: + - 检查 `PROD_HOST`、`PROD_USER`、`PROD_SSH_KEY` 是否正确 + - 确认服务器防火墙允许 SSH 端口 + - 测试 SSH 连接:`ssh -i ~/.ssh/github-actions-deploy user@your-server` + +2. **镜像推送失败**: + - 检查 GitHub Packages 权限 + - 确认 `GITHUB_TOKEN` 有写入权限 + +3. **部署失败**: + - 查看 GitHub Actions 日志中的错误信息 + - 检查服务器上的 Docker 和 docker-compose 是否正常 + +### 7.2 健康检查失败 + +```bash +# 检查服务是否运行 +docker-compose -f docker-compose.prod.yml ps + +# 检查后端日志 +docker-compose -f docker-compose.prod.yml logs backend + +# 检查 Nginx 日志 +docker-compose -f docker-compose.prod.yml logs nginx + +# 手动测试健康检查 +curl -v https://your-domain.com/api/health +``` + +--- + +## 八、安全检查清单 + +- [ ] GitHub 仓库设置为 Private(如果包含敏感信息) +- [ ] 所有敏感信息存储在 `.env.prod`(不提交到 Git) +- [ ] SSH 密钥使用强加密(ed25519) +- [ ] 生产服务器防火墙配置正确(只开放必要端口) +- [ ] SSL 证书配置正确(HTTPS) +- [ ] 定期更新 Docker 镜像和依赖 + +--- + +## 九、参考文档 + +- [GitHub Actions 文档](https://docs.github.com/en/actions) +- [GitHub Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) +- [生产部署文档](./deploy.md) +- [企业微信回调配置](./wecom.md) diff --git a/docs/phase1.md b/docs/phase1.md new file mode 100644 index 0000000..5bd45a1 --- /dev/null +++ b/docs/phase1.md @@ -0,0 +1,51 @@ +# Phase 1:初始化仓库与脚手架 + 本地跑通 + +## 目录结构 + +``` +backend/ # FastAPI,routers/services/models,config 来自 env +admin/ # Next.js + TypeScript + Ant Design +deploy/ # nginx.conf +docs/ # wecom.md, phase*.md +docker-compose.yml +.env.example +``` + +## 本地启动 + +1. 复制环境变量: + - **Bash:** `cp .env.example .env` + - **PowerShell:** `Copy-Item .env.example .env` +2. 一键启动(需本机已装 Docker + docker-compose): + ```bash + docker-compose up -d + ``` +3. 等待 db 健康后,backend 与 admin 会启动;nginx 监听 80。 + +## 验证 + +- **健康检查**:`curl http://localhost:8000/api/health` 或经 nginx `curl http://localhost/api/health` + - 期望:`{"code":0,"message":"ok","data":{"status":"up"},"trace_id":"..."}` +- **管理后台**:浏览器打开 `http://localhost:3000` 或 `http://localhost`(经 nginx) + - 登录页,用户名/密码填 `admin`/`admin`,点击登录应提示「登录成功」(当前为占位校验) +- **后端测试**:在 `backend/` 下执行 `pytest tests/test_health.py -v` + +## 关键配置项(.env) + +- `DATABASE_URL` / `DATABASE_URL_SYNC`:Phase 2 迁移会用。 +- `NEXT_PUBLIC_API_BASE`:前端请求 API 的地址;本地直连后端可设为 `http://localhost:8000`,经 nginx 可留空或 `http://localhost`。 + +## 仅本地跑后端(不 Docker) + +```bash +cd backend +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +# 先启动 PostgreSQL 并建库 wecom_ai、用户 wecom +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +前端单独起: +- **Bash:** `cd admin && npm install && npm run dev`,且设置 `NEXT_PUBLIC_API_BASE=http://localhost:8000`(可在 `admin/.env.local` 写一行)。 +- **PowerShell:** 先 `cd admin`,再 `npm install`,再 `$env:NEXT_PUBLIC_API_BASE="http://localhost:8000"; npm run dev`(或把 `NEXT_PUBLIC_API_BASE=http://localhost:8000` 写入 `admin/.env.local` 后直接 `npm run dev`)。 diff --git a/docs/phase2.md b/docs/phase2.md new file mode 100644 index 0000000..2ea55c3 --- /dev/null +++ b/docs/phase2.md @@ -0,0 +1,50 @@ +# Phase 2:后端基础 API + 数据库迁移 + +## 新增内容 + +- **ORM**:`app/models/`(User, ChatSession, Message, Ticket),SQLAlchemy 2.x +- **数据库**:`app/database.py` 异步会话,`get_db` 依赖 +- **迁移**:Alembic,`alembic/versions/001_init_tables.py`、`002_seed_admin.py` +- **认证**:`app/services/auth_service.py`(bcrypt + JWT),`app/deps.py`(get_current_user_id) +- **登录**:`POST /api/auth/login` 查 User 表并校验密码,返回 JWT + +## 本地启动与迁移 + +1. 启动 DB(或整栈): + ```bash + docker-compose up -d db + ``` +2. 在 `backend/` 下执行迁移(需已配置 `DATABASE_URL_SYNC`,如从项目根 `.env` 加载): + ```bash + cd backend + # 若 .env 在项目根,可:export $(grep -v '^#' ../.env | xargs) 或复制 .env 到 backend/ + alembic upgrade head + ``` +3. 启动后端: + ```bash + uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + ``` + +## 验证 + +- **健康检查**:`curl http://localhost:8000/api/health` → `code: 0, data.status: "up"` +- **登录**:`curl -X POST http://localhost:8000/api/auth/login -H "Content-Type: application/json" -d "{\"username\":\"admin\",\"password\":\"admin\"}"` + - 迁移并 seed 后应返回 `code: 0` 且 `data.access_token` 为 JWT +- **测试**:`pytest tests/test_health.py tests/test_auth.py -v` + +## 关键配置项 + +- `DATABASE_URL`:异步连接串,供 FastAPI 使用(如 `postgresql+asyncpg://...`) +- `DATABASE_URL_SYNC`:同步连接串,供 Alembic 使用(如 `postgresql://...`) +- `JWT_SECRET`:生产环境必须修改 +- `JWT_EXPIRE_MINUTES`:token 有效期(分钟) + +## Docker 内迁移 + +若后端在 Docker 内启动,可在首次启动时自动迁移(可选)。例如在 `backend/Dockerfile` 或启动脚本中: + +```bash +alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +需保证容器内能访问 `DATABASE_URL_SYNC` 且指向 `db:5432`。 diff --git a/docs/phase3.md b/docs/phase3.md new file mode 100644 index 0000000..1f23863 --- /dev/null +++ b/docs/phase3.md @@ -0,0 +1,35 @@ +# Phase 3:管理后台基础页面 + API 联通 + +## 新增内容 + +- **前端**:Next.js App Router,路由组 `(main)` 下会话/知识库/设置共用布局 +- **登录**:`/login` → 存 token 到 localStorage → 跳转 `/sessions` +- **会话列表**:`/sessions`,调用 `GET /api/sessions`,表格 + 查看消息链接 +- **会话详情**:`/sessions/[id]`,消息列表 + 手动回复输入 + 「转人工/创建工单」按钮 +- **知识库**:`/knowledge`,拖拽上传,调用 `POST /api/knowledge/upload`(占位) +- **设置**:`/settings`,调用 `GET /api/settings`(占位) +- **API 封装**:`lib/api.ts`,统一带 `Authorization: Bearer ` + +## 本地启动 + +1. 后端与 DB 已起(Phase 2):`docker-compose up -d db backend` 或全栈 `docker-compose up -d` +2. 前端(从项目根): + - **Bash:** `cd admin` → `npm install` → `npm run dev` + - **PowerShell:** `cd admin` → `npm install` → 设置 API 再启动: + ```powershell + $env:NEXT_PUBLIC_API_BASE="http://localhost:8000"; npm run dev + ``` + 或新建 `admin/.env.local` 内容 `NEXT_PUBLIC_API_BASE=http://localhost:8000`,然后 `npm run dev` +3. 浏览器打开 `http://localhost:3000`,根路径会重定向到 `/login` 或 `/sessions` + +## 验证 + +- 使用 `admin` / `admin` 登录(需已执行迁移并 seed) +- 会话列表可为空(Phase 5 入库后会有数据) +- 会话详情页可点「转人工/创建工单」、输入内容点「发送」(后端占位返回成功) +- 知识库页拖拽上传任意文件,应提示上传成功(占位) +- 设置页展示空对象 `{}` + +## 关键配置 + +- `NEXT_PUBLIC_API_BASE`:前端请求的后端地址。本地直连填 `http://localhost:8000`;经 nginx 填 `http://localhost` 或留空(同域) diff --git a/docs/phase4.md b/docs/phase4.md new file mode 100644 index 0000000..29c8e7c --- /dev/null +++ b/docs/phase4.md @@ -0,0 +1,18 @@ +# Phase 4:企业微信回调打通(GET 验签 + POST echo) + +## 新增内容 + +- **GET /api/wecom/callback**:使用 `WECOM_TOKEN`、`WECOM_ENCODING_AES_KEY` 验签并解密 `echostr`,将解密结果原样返回。 +- **POST /api/wecom/callback**:解析加密 XML,验签、解密、解析消息,文本消息 echo 回复,回复加密后以 XML 返回。 +- **加解密**:`app/services/wecom_crypto.py`(SHA1 验签、AES-256-CBC 加解密,与企微文档一致)。 + +## 验证 + +1. 在企微管理后台配置回调 URL:`https://你的域名/api/wecom/callback`,填 Token、EncodingAESKey。 +2. 保存后企微会发 GET 校验,服务端应返回解密后的 echostr,后台显示「保存成功」。 +3. 给应用发一条文本消息,应收到 echo 回复。 + +## 关键配置 + +- `WECOM_TOKEN`、`WECOM_ENCODING_AES_KEY` 必须与企微后台一致。 +- `WECOM_CORP_ID` 用于回复加密尾部;POST 回复时需正确。 diff --git a/docs/phase5.md b/docs/phase5.md new file mode 100644 index 0000000..62b7f11 --- /dev/null +++ b/docs/phase5.md @@ -0,0 +1,18 @@ +# Phase 5:会话入库 + 后台可看 + +## 新增内容 + +- **回调入库**:WeCom POST 回调解析到客户消息后,`get_or_create_session` + `add_message`(user/assistant)写入 DB。 +- **会话列表**:`GET /api/sessions` 从 `chat_sessions` 表读取,需 Bearer token。 +- **消息列表**:`GET /api/sessions/{session_id}/messages` 从 `messages` 表读取,需 Bearer token。 + +## 验证 + +1. 配置好 WeCom 回调 URL、Token、EncodingAESKey,并确保回调可访问(公网或 ngrok)。 +2. 在企微侧给应用发一条文本消息,触发 POST 回调。 +3. 管理后台登录后打开「会话列表」,应出现一条会话;点「查看消息」应看到用户消息与机器人 echo 回复。 + +## 关键点 + +- 外部客户仅使用 public 知识;回调中只存消息内容与 external_user_id,不落内部配置。 +- 会话以 `external_user_id`(企微 FromUserName)唯一,同一客户多条消息归同一会话。 diff --git a/docs/phase6.md b/docs/phase6.md new file mode 100644 index 0000000..2e4a133 --- /dev/null +++ b/docs/phase6.md @@ -0,0 +1,17 @@ +# Phase 6:转人工工单 + 手动回复(企业微信发消息) + +## 新增内容 + +- **创建工单**:`POST /api/tickets`,body `session_id`、`reason`;插入 `tickets` 表并将对应会话 `status` 置为 `transferred`。 +- **手动回复**:`POST /api/tickets/reply`,body `session_id`、`content`;根据会话查 `external_user_id`,调用企业微信「发送消息给外部联系人」API 下发文本。 +- **WeCom API 封装**:`app/services/wecom_api.py`,`get_access_token`、`send_text_to_external`,带超时与重试。 + +## 验证 + +1. 在管理后台进入某会话详情,点击「转人工/创建工单」→ 应提示工单已创建。 +2. 在输入框输入内容点「发送」→ 客户端(企微侧)应收到该条消息(需配置好 `WECOM_CORP_ID`、`WECOM_SECRET`、`WECOM_AGENT_ID`)。 + +## 配置与接口说明 + +- 发消息接口以当前实现为准(如 `externalcontact/message/send` 或 `externalcontact/send_message_to_user`);若企微返回 4xx/5xx,请对照官方文档调整 URL 与参数。 +- `sender` 当前使用 `WECOM_AGENT_ID`;若需指定客服成员,可后续增加 `WECOM_SENDER_USERID` 配置。 diff --git a/docs/phase7.md b/docs/phase7.md new file mode 100644 index 0000000..b8e390b --- /dev/null +++ b/docs/phase7.md @@ -0,0 +1,35 @@ +# Phase 7:云端 CI/CD + 线上回调稳定 + 最小闭环验收 + +## CI/CD(GitHub Actions) + +- **位置**:`.github/workflows/build-deploy.yml`(副本说明见 `deploy/ci/`) +- **触发**:推送到 `main` 分支 +- **流程**: + 1. **test-backend**:启动 PostgreSQL 16,执行迁移,运行 `pytest tests/` + 2. **build-backend**:构建 backend 镜像并推送到 GitHub Container Registry(`ghcr.io//wecom-ai-backend:latest`) + 3. **build-admin**:构建 admin 镜像并推送(`ghcr.io//wecom-ai-admin:latest`) + +## 云端部署(方案 A) + +1. 服务器安装 Docker + docker-compose。 +2. 从 GHCR 拉取镜像(或从仓库 build): + - 使用 `docker-compose.yml` 时,可改为使用 `image: ghcr.io//wecom-ai-backend:latest` 等,不再本地 build。 +3. 配置 `.env`(含 `WECOM_*`、`DATABASE_URL`、`JWT_SECRET` 等)。 +4. 执行:`docker-compose up -d`。 +5. 配置 Nginx 反代或直接暴露端口;HTTPS 证书后补。 + +## 无 GitHub 时(GitLab CI 替代) + +在仓库根目录创建 `.gitlab-ci.yml`,阶段与上述对应: +`test`(postgres service + migrate + pytest)、`build`(docker build + push 到 GitLab Registry)。变量使用 `CI_REGISTRY`、`CI_REGISTRY_USER`、`CI_REGISTRY_PASSWORD`。 + +## 最小闭环验收脚本 + +- **路径**:`deploy/scripts/acceptance.sh` +- **用法**:`BASE_URL=http://localhost ./acceptance.sh` 或 `BASE_URL=https://你的域名 ./acceptance.sh` +- **步骤**: + 1. `GET /api/health` 期望 200 + 2. `POST /api/auth/login`(admin/admin)期望 `code: 0` + 3. `GET /api/wecom/callback?...` 期望 200 或 400(验签失败为 400) + +全部通过即打印 "All checks passed"。 diff --git a/docs/setup-steps.md b/docs/setup-steps.md new file mode 100644 index 0000000..2086ac1 --- /dev/null +++ b/docs/setup-steps.md @@ -0,0 +1,240 @@ +# 企业微信回调配置完整步骤 + +## 当前状态检查 + +✅ Docker 服务运行正常 +✅ 后端服务正常(http://localhost:8000) +✅ .env 文件已配置企业微信参数 + +--- + +## 步骤 1:安装 cloudflared + +### 方法 A:使用 MSI 安装包(推荐,最简单) + +1. **下载安装包**: + - 直接下载:https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.msi + - 或访问:https://github.com/cloudflare/cloudflared/releases 选择最新版本 + +2. **安装**: + - 双击下载的 `cloudflared-windows-amd64.msi` 文件 + - 按照安装向导完成安装 + - 安装完成后会自动添加到系统 PATH + +3. **验证安装**: + ```powershell + cloudflared --version + ``` + 应该显示版本号,例如:`cloudflared 2026.1.2` + +### 方法 B:使用 Scoop(如果已安装) + +```powershell +scoop install cloudflared +``` + +--- + +## 步骤 2:启动 Cloudflare Tunnel + +### 2.1 启动 tunnel + +在 PowerShell 中运行: + +```powershell +cloudflared tunnel --url http://localhost:8000 +``` + +### 2.2 复制公网 URL + +启动后会显示类似这样的输出: + +``` ++--------------------------------------------------------------------------------------------+ +| Your quick Tunnel has been created! Visit it at: | +| https://abc123-def456-ghi789.trycloudflare.com | ++--------------------------------------------------------------------------------------------+ +``` + +**重要**:复制这个 URL(例如:`https://abc123-def456-ghi789.trycloudflare.com`) + +### 2.3 保持窗口运行 + +**重要**:不要关闭这个 PowerShell 窗口,保持 cloudflared 运行。 + +--- + +## 步骤 3:配置企业微信后台 + +### 3.1 登录企业微信管理后台 + +访问:https://work.weixin.qq.com + +### 3.2 进入应用设置 + +1. 点击 **应用管理** +2. 选择 **自建应用** +3. 选择你的应用(AgentId: 1000003) +4. 找到 **接收消息** 或 **API接收** +5. 点击 **设置 API 接收** 或 **配置回调URL** + +### 3.3 填写回调配置 + +根据你的 `.env` 文件,填写以下信息: + +| 配置项 | 值 | 说明 | +|--------|-----|------| +| **接收消息服务器 URL** | `https://你的cloudflared域名.trycloudflare.com/api/wecom/callback` | 将 `你的cloudflared域名` 替换为步骤 2.2 中复制的域名 | +| **Token** | `tuqA13S8A9L2` | 与 `.env` 中的 `WECOM_TOKEN` 一致 | +| **EncodingAESKey** | `VjrVc9NuGr1pikvwKLjr2OpB6gh1wDlBaUMKeUHAlKw` | 与 `.env` 中的 `WECOM_ENCODING_AES_KEY` 一致 | +| **消息加解密方式** | **安全模式** | 推荐选择安全模式 | + +**示例**: +- 如果 cloudflared URL 是:`https://abc123-def456-ghi789.trycloudflare.com` +- 那么回调 URL 应该是:`https://abc123-def456-ghi789.trycloudflare.com/api/wecom/callback` + +### 3.4 保存配置 + +点击 **保存** 按钮。 + +--- + +## 步骤 4:验证配置 + +### 4.1 查看后端日志 + +在另一个 PowerShell 窗口中运行: + +```powershell +docker compose logs backend -f +``` + +### 4.2 检查验证结果 + +保存配置后,企业微信会立即发送 GET 请求进行校验。 + +**成功标志**: +- 后端日志中应该看到: + ``` + INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} + ``` +- 企业微信后台应显示 **保存成功** ✅ + +**失败标志**: +- 后端日志显示 `wecom verify failed` +- 企业微信后台显示"URL校验失败" + +**如果失败,检查**: +1. Token 是否与 `.env` 中的 `WECOM_TOKEN` 完全一致 +2. EncodingAESKey 是否与 `.env` 中的 `WECOM_ENCODING_AES_KEY` 完全一致(43位,不含等号) +3. 回调 URL 是否正确(包含 `/api/wecom/callback`) +4. cloudflared 是否仍在运行 + +--- + +## 步骤 5:测试消息回调 + +### 5.1 发送测试消息 + +1. 打开企业微信客户端 +2. 找到你配置的应用 +3. 发送一条文本消息,例如:`你好,测试一下` + +### 5.2 查看后端日志 + +在后端日志中应该看到: + +**收到消息**: +```json +{ + "level": "INFO", + "message": "wecom message received", + "trace_id": "...", + "external_userid": "wmxxxxx", + "msgid": "1234567890", + "msg_type": "text", + "content_summary": "你好,测试一下" +} +``` + +**发送回复**: +```json +{ + "level": "INFO", + "message": "wecom reply sent", + "trace_id": "...", + "external_userid": "wmxxxxx", + "msgid": "1234567890", + "reply_summary": "已收到:你好,测试一下" +} +``` + +### 5.3 验证回复 + +在企业微信客户端中,应该收到回复:**`已收到:你好,测试一下`** + +### 5.4 检查数据库 + +```powershell +# 查看会话 +docker compose exec db psql -U wecom -d wecom_ai -c "SELECT id, external_user_id, status, created_at FROM chat_sessions ORDER BY created_at DESC LIMIT 5;" + +# 查看消息 +docker compose exec db psql -U wecom -d wecom_ai -c "SELECT id, session_id, role, content, created_at FROM messages ORDER BY created_at DESC LIMIT 10;" +``` + +--- + +## 常见问题 + +### Q1: cloudflared 命令找不到? + +**解决**: +- 如果使用 MSI 安装,重启 PowerShell 窗口 +- 或使用完整路径运行:`C:\Program Files\Cloudflare\cloudflared.exe tunnel --url http://localhost:8000` + +### Q2: GET 校验失败? + +**检查清单**: +- [ ] Token 是否与 `.env` 中的 `WECOM_TOKEN` 完全一致(包括大小写、空格) +- [ ] EncodingAESKey 是否与 `.env` 中的 `WECOM_ENCODING_AES_KEY` 完全一致(43位) +- [ ] 回调 URL 是否正确(格式:`https://域名.trycloudflare.com/api/wecom/callback`) +- [ ] cloudflared 是否仍在运行 +- [ ] 后端服务是否正常运行 + +### Q3: 收到消息但没有回复? + +**检查**: +- 查看后端日志是否有错误 +- 检查数据库连接是否正常 +- 确认消息类型是否为文本消息 + +--- + +## 快速命令参考 + +```powershell +# 检查服务状态 +docker compose ps + +# 查看后端日志 +docker compose logs backend -f + +# 测试本地服务 +curl http://localhost:8000/api/health + +# 启动 cloudflared +cloudflared tunnel --url http://localhost:8000 + +# 检查数据库 +docker compose exec db psql -U wecom -d wecom_ai -c "SELECT * FROM chat_sessions;" +``` + +--- + +## 下一步 + +完成配置后,可以: +1. 测试不同类型的消息(文本、图片等) +2. 查看管理后台的会话列表和消息记录 +3. 继续开发阶段 5:FAQ 匹配功能 diff --git a/docs/stage1.md b/docs/stage1.md new file mode 100644 index 0000000..d537de7 --- /dev/null +++ b/docs/stage1.md @@ -0,0 +1,34 @@ +# 阶段 1:Monorepo 骨架 + Docker Compose 本地可启动 + +## 目录结构 + +``` +backend/ # FastAPI,/api/health、/api/ready +admin/ # Next.js + TS,/login、/dashboard 占位 +deploy/ # nginx.conf +docs/ # 本文件等 +docker-compose.yml +.env.example +README.md +``` + +## 本地启动 + +1. 复制环境变量:`cp .env.example .env`(PowerShell: `Copy-Item .env.example .env`) +2. 启动:`docker compose up -d` +3. 访问:http://localhost(nginx)、http://localhost:3000(admin 直连)、http://localhost:8000(backend 直连) + +## 验证 + +- `GET http://localhost/api/health` → `{"status":"up","service":"backend"}` +- `GET http://localhost/api/ready` → `{"ready":true,"service":"backend"}` +- 打开 http://localhost → 重定向到 /login,可点「去 Dashboard」到 /dashboard + +## 端口 + +| 服务 | 端口 | +|--------|------| +| nginx | 80 | +| admin | 3000 | +| backend| 8000 | +| db | 5432 | diff --git a/docs/stage2.md b/docs/stage2.md new file mode 100644 index 0000000..094de94 --- /dev/null +++ b/docs/stage2.md @@ -0,0 +1,160 @@ +# 阶段 2:SQLAlchemy + Alembic + 用户登录体系 + +## 1. 数据库与迁移 + +- **连接**:backend 通过环境变量 `DATABASE_URL`(异步 `postgresql+asyncpg://...`)连接 PostgreSQL;Alembic 使用 `DATABASE_URL_SYNC`(同步 `postgresql://...`)。 +- **表结构**: + - **users**:id (uuid)、username、password_hash、role、is_active、created_at + - **audit_logs**:id (uuid)、actor_user_id (FK users)、action、meta_json (JSONB)、created_at + +### 迁移流程说明 + +Alembic 会按版本顺序执行 `alembic/versions/` 下的迁移脚本,在数据库中创建或修改表,并在库中记录当前版本(表 `alembic_version`)。 +执行时: + +1. 读取 `backend/alembic.ini` 和 `backend/alembic/env.py`。 +2. `env.py` 从 `app.config.settings` 读取 `database_url_sync`(即环境变量 `DATABASE_URL_SYNC`),用该同步连接串连接 PostgreSQL。 +3. 对比数据库中的 `alembic_version` 与本地迁移文件,将尚未执行的迁移按顺序执行(例如 `001_users_and_audit_logs.py` 会创建 `users`、`audit_logs` 表)。 + +--- + +### 方式 A:Docker 启动时自动迁移 + +**步骤:** + +1. 在项目根目录执行: + ```bash + docker compose up -d + ``` +2. Compose 会先启动 `db`,等待健康检查通过后再启动 `backend`。 +3. **backend 容器**的启动命令为: + ```text + sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" + ``` +4. **迁移阶段**: + - 容器内当前目录为 `/app`(即 backend 代码根目录),`PYTHONPATH=/app`。 + - 执行 `alembic upgrade head` 时,Alembic 在 `/app` 下找到 `alembic.ini`、`alembic/env.py` 和 `alembic/versions/`。 + - 环境变量由 `docker-compose` 注入(含 `DATABASE_URL_SYNC=postgresql://wecom:wecom_secret@db:5432/wecom_ai`,注意主机名为 `db`)。 + - `env.py` 通过 `app.config.settings` 读到该连接串,用**同步**驱动连接 PostgreSQL,执行所有未执行的迁移(如创建 `users`、`audit_logs`)。 +5. **应用启动**:迁移成功后执行 `uvicorn ...`,FastAPI 启动。 + +**注意**:若迁移失败(例如数据库未就绪、连接串错误),整条 CMD 会失败,容器退出,backend 不会起来。可查看日志:`docker compose logs backend`。 + +**一键迁移脚本(Docker)**:在项目根目录执行其一即可完成「起 db → 等就绪 → 执行 alembic upgrade head」: + +- **Windows PowerShell**:`.\deploy\scripts\migrate.ps1` +- **Linux / macOS**:`bash deploy/scripts/migrate.sh` +- **任意平台(Python)**:`python deploy/scripts/migrate.py`(默认 Docker);本机迁移用 `python deploy/scripts/migrate.py --local` + +--- + +### 方式 B:本机执行迁移 + +适用于在本机用 Python 直接跑迁移、数据库可在本机访问(本地 PostgreSQL 或已映射端口的 Docker 数据库)的情况。 + +**前提**: + +- 本机已安装 Python 3.12、PostgreSQL 客户端库(`psycopg2-binary`)。 +- 数据库已存在(例如 Docker 只起 db:`docker compose up -d db`),且已知连接信息。 + +**步骤:** + +1. **进入 backend 目录**(迁移必须在 backend 根目录执行,以便找到 `alembic.ini` 和 `alembic/`): + ```bash + cd backend + ``` + +2. **准备环境变量**(二选一): + - 在项目根目录已有 `.env` 时,可在 backend 下复制一份,让 `app.config` 自动读取: + ```bash + cp ../.env .env + ``` + - 或直接设置连接串(PowerShell 示例): + ```powershell + $env:DATABASE_URL_SYNC = "postgresql://wecom:wecom_secret@localhost:5432/wecom_ai" + ``` + Linux/macOS: + ```bash + export DATABASE_URL_SYNC=postgresql://wecom:wecom_secret@localhost:5432/wecom_ai + ``` + 若数据库在 Docker 且未改端口,主机填 `localhost`、端口 `5432` 即可。 + +3. **安装依赖**(若未装过): + ```bash + pip install -r requirements.txt + ``` + +4. **执行迁移**: + ```bash + alembic upgrade head + ``` + - Alembic 会读取当前目录下的 `alembic.ini` 和 `alembic/env.py`。 + - `env.py` 里会 `from app.config import settings`、`from app.models import Base`,因此当前目录必须在 backend(或设置 `PYTHONPATH` 指向 backend),这样 `app` 才能正确解析。 + - 执行后,数据库中会创建/更新表,并写入 `alembic_version`。 + +5. **验证**:连接数据库查看是否有 `users`、`audit_logs` 及 `alembic_version` 表。 + +**本机常见问题**: + +- 报错 `No module named 'app'`:未在 `backend` 目录执行,或未设置 `PYTHONPATH`。解决:`cd backend` 再执行,或 `PYTHONPATH=backend alembic -c backend/alembic.ini upgrade head`(在项目根目录时)。 +- 连接被拒绝:检查 `DATABASE_URL_SYNC` 的主机、端口、用户名、密码、数据库名是否与真实 PostgreSQL 一致(Docker 时主机为 `localhost`,端口一般为 `5432`)。 + +## 2. 如何创建管理员 + +使用 seed 脚本(从 **项目根目录** 执行,依赖已安装的 Python 和 .env): + +```bash +# 确保 .env 存在,且 DATABASE_URL_SYNC 指向数据库(Docker 时用 localhost:5432) +pip install python-dotenv bcrypt psycopg2-binary sqlalchemy + +python deploy/scripts/seed.py +``` + +可选环境变量(在 .env 或导出): + +- `ADMIN_USERNAME`:默认 `admin` +- `ADMIN_PASSWORD`:默认 `admin` +- `DATABASE_URL_SYNC`:同步连接串,Docker 时一般为 `postgresql://wecom:wecom_secret@localhost:5432/wecom_ai` + +脚本会检查 `users` 表是否存在;若不存在会提示先执行迁移。若用户名已存在则跳过创建。 + +## 3. Auth API + +- **POST /api/auth/login** + - Body: `{"username":"admin","password":"admin"}` + - 成功:200,`{"access_token":"...","token_type":"bearer"}` + - 失败:401/403 + +- **GET /api/auth/me** + - Header: `Authorization: Bearer ` + - 成功:200,当前用户信息(id、username、role、is_active、created_at) + - 失败:401/403 + +## 4. 管理后台 + +- **Token 存储**:使用 **localStorage**,key 为 `"token"`。实现见 `admin/lib/api.ts` 注释。生产环境可改为 httpOnly Cookie 由后端 Set-Cookie。 +- **登录页 /login**:表单提交后调用 `POST /api/auth/login`,成功则写入 localStorage 并跳转 `/dashboard`。 +- **Dashboard /dashboard**:需登录;进入时请求 `GET /api/auth/me`,失败则清除 token 并跳转 `/login`;成功则展示当前用户信息与退出按钮。 +- **根路径 /**:有 token 则跳转 `/dashboard`,否则跳转 `/login`。 + +## 5. 安全 + +- **密码**:bcrypt(passlib + bcrypt 4.1.2)。 +- **JWT**:有过期时间,由 `JWT_EXPIRE_MINUTES` 控制(默认 60 分钟);密钥 `JWT_SECRET` 需在生产环境修改。 + +## 6. 如何登录验证 + +1. 启动:`docker compose up -d`(自动执行迁移)。 +2. 创建管理员:`python deploy/scripts/seed.py`(见上文)。 +3. 打开 http://localhost 或 http://localhost:3000,应跳转登录页。 +4. 输入 admin / admin(或你在 .env 里设置的 `ADMIN_USERNAME` / `ADMIN_PASSWORD`),提交后应跳转 Dashboard 并显示当前用户信息。 +5. 点击退出后应回到登录页;再次访问 /dashboard 应被重定向到 /login。 + +直连 API 验证: + +```bash +# 登录取 token +TOKEN=$(curl -s -X POST http://localhost:8000/api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"admin"}' | jq -r .access_token) +# 当前用户 +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/auth/me +``` diff --git a/docs/stage3.md b/docs/stage3.md new file mode 100644 index 0000000..3587982 --- /dev/null +++ b/docs/stage3.md @@ -0,0 +1,38 @@ +# 阶段 3:管理后台闭环页面结构 + 后端占位 API + +## 页面结构(Next.js 路由) + +- **/dashboard**:总览占位(统计卡片:会话总数、工单总数、当前用户) +- **/sessions**:会话列表(表格:ID、外部用户ID、昵称、状态、创建时间、操作) +- **/sessions/[id]**:会话详情(消息列表 + 创建工单按钮 + 回复输入框占位) +- **/tickets**:工单列表(表格:ID、会话、原因、状态、创建时间、操作) +- **/kb**:知识库(上传拖拽区 + 文档列表表格) +- **/settings**:模型/策略设置(表单:模型名称、FAQ 优先、启用 RAG) +- **/users**:用户管理(仅管理员可见,表格 + 新建/删除) + +## 后端 API(统一 code/data/message/trace_id,需 JWT) + +- **GET /api/admin/sessions**:会话列表 +- **GET /api/admin/sessions/{id}**:会话详情(含消息列表) +- **GET /api/admin/tickets**:工单列表 +- **POST /api/admin/tickets**:创建工单(body: session_id, reason) +- **PATCH /api/admin/tickets/{id}**:更新工单(body: status, reason) +- **GET /api/admin/kb/docs**:知识库文档列表 +- **POST /api/admin/kb/docs/upload**:上传文档(multipart/form-data) +- **GET /api/admin/settings**:获取设置 +- **PATCH /api/admin/settings**:更新设置(body: model_name, strategy) +- **GET /api/admin/users**:用户列表(仅管理员) +- **POST /api/admin/users**:创建用户(仅管理员) +- **PATCH /api/admin/users/{id}**:更新用户(仅管理员) +- **DELETE /api/admin/users/{id}**:删除用户(仅管理员) + +## 前端 API Client + +- **admin/lib/api.ts**:统一封装 `adminApi()`,自动带 Bearer token,返回 `ApiRes`(code/data/message/trace_id) +- 所有函数返回 `Promise>`,401 时自动清除 token + +## 验证 + +1. 登录后应看到顶部导航:总览 | 会话列表 | 工单列表 | 知识库 | 设置 | 用户管理 +2. 各页面应能正常加载并显示占位数据(表格、卡片、表单等) +3. 用户管理页面:非管理员访问应提示「仅管理员可访问」;管理员可看到用户列表并可新建/删除 diff --git a/docs/wecom-test-guide.md b/docs/wecom-test-guide.md new file mode 100644 index 0000000..59f2ccd --- /dev/null +++ b/docs/wecom-test-guide.md @@ -0,0 +1,544 @@ +# 企业微信回调功能测试详细流程 + +## 一、前置准备 + +### 1.1 检查服务状态 + +```bash +# 检查所有服务是否运行 +docker compose ps + +# 应该看到: +# - ai-db-1 (Running) +# - ai-backend-1 (Running) +# - ai-admin-1 (Running,可选) +``` + +### 1.2 检查后端日志 + +```bash +# 查看后端最新日志,确认无错误 +docker compose logs backend --tail 50 + +# 应该看到: +# INFO: Uvicorn running on http://0.0.0.0:8000 +# INFO: Application startup complete. +``` + +### 1.3 配置环境变量 + +编辑 `.env` 文件,确保以下企业微信相关配置已填写: + +```bash +# 企业微信配置(必须) +WECOM_CORP_ID=你的企业ID # 从"我的企业 → 企业信息"获取 +WECOM_AGENT_ID=你的应用AgentId # 从"自建应用 → 应用详情"获取 +WECOM_SECRET=你的应用Secret # 从"自建应用 → 应用详情"获取(可选,用于主动发送消息) +WECOM_TOKEN=你的Token # 自定义字符串,需与企微后台一致 +WECOM_ENCODING_AES_KEY=你的43位密钥 # 从企微后台获取,43位Base64编码 +WECOM_API_BASE=https://qyapi.weixin.qq.com +WECOM_API_TIMEOUT=10 +WECOM_API_RETRIES=2 +``` + +**重要提示**: +- `WECOM_TOKEN` 和 `WECOM_ENCODING_AES_KEY` 必须与企微后台配置**完全一致** +- `WECOM_ENCODING_AES_KEY` 是 43 位 Base64 编码字符串(不含等号) + +### 1.4 重启后端服务(如果修改了 .env) + +```bash +# 重启后端以加载新的环境变量 +docker compose restart backend + +# 等待几秒后检查日志 +docker compose logs backend --tail 20 +``` + +--- + +## 二、企业微信后台配置 + +### 2.1 获取必要参数 + +1. **登录企业微信管理后台**:https://work.weixin.qq.com + +2. **获取企业 ID (CorpId)**: + - 进入 **我的企业** → **企业信息** + - 复制 **企业 ID**,填入 `.env` 的 `WECOM_CORP_ID` + +3. **获取应用信息**: + - 进入 **应用管理** → **自建应用** → 选择你的应用 + - 在 **应用详情** 中获取: + - **AgentId**:填入 `.env` 的 `WECOM_AGENT_ID` + - **Secret**:填入 `.env` 的 `WECOM_SECRET`(可选) + +### 2.2 配置回调 URL(关键步骤) + +1. **进入接收消息设置**: + - 在应用详情页面,找到 **接收消息** 或 **API接收** + - 点击 **设置 API 接收** 或 **配置回调URL** + +2. **填写回调配置**: + - **接收消息服务器 URL**:`https://你的公网域名/api/wecom/callback` + - 本地测试:使用 ngrok 等工具暴露本地服务(见下方) + - 生产环境:使用你的实际域名 + - **Token**:填写一个自定义字符串(例如:`my_wecom_token_2025`) + - **重要**:这个 Token 必须与 `.env` 中的 `WECOM_TOKEN` **完全一致** + - **EncodingAESKey**: + - 点击 **随机获取** 或手动输入 43 位 Base64 编码字符串 + - **重要**:这个密钥必须与 `.env` 中的 `WECOM_ENCODING_AES_KEY` **完全一致** + - **消息加解密方式**:选择 **安全模式**(推荐)或 **明文模式**(仅测试) + +3. **保存配置**: + - 点击 **保存** 按钮 + - 企业微信会立即发送 GET 请求到你的回调 URL 进行校验 + - 如果配置正确,会显示 **保存成功** ✅ + +--- + +## 三、本地测试方法 + +### 方法 1:使用 Cloudflare Tunnel(推荐 ⭐⭐⭐⭐⭐) + +Cloudflare Tunnel (cloudflared) 提供免费的固定域名,比 ngrok 更稳定。 + +#### 3.1 安装 cloudflared + +**Windows(使用 Scoop)**: +```powershell +# 安装 Scoop(如果还没有) +Set-ExecutionPolicy RemoteSigned -Scope CurrentUser +irm get.scoop.sh | iex + +# 安装 cloudflared +scoop install cloudflared +``` + +**或使用 Chocolatey**: +```powershell +choco install cloudflared +``` + +**或手动下载**: +- 访问:https://github.com/cloudflare/cloudflared/releases +- 下载 `cloudflared-windows-amd64.exe`,重命名为 `cloudflared.exe` + +#### 3.2 启动 cloudflared + +```powershell +# 在项目根目录运行(暴露本地 8000 端口) +cloudflared tunnel --url http://localhost:8000 +``` + +**输出示例**: +``` +2025-02-05T10:00:00Z INF +--------------------------------------------------------------------------------------------+ +2025-02-05T10:00:00Z INF | Your quick Tunnel has been created! Visit it at: | +2025-02-05T10:00:00Z INF | https://abc123-def456-ghi789.trycloudflare.com | +2025-02-05T10:00:00Z INF +--------------------------------------------------------------------------------------------+ +``` + +#### 3.3 获取公网 URL + +从输出中复制 `https://xxx.trycloudflare.com`,这就是你的公网 URL。 + +**注意**: +- 这个 URL 在本次运行期间是固定的 +- 关闭 cloudflared 后,URL 会失效 +- 如需固定域名,请登录 Cloudflare 创建命名 tunnel(见 `docs/cloudflared-setup.md`) + +#### 3.4 配置企微后台 + +在企微后台的回调 URL 中填写:`https://你的cloudflared域名.trycloudflare.com/api/wecom/callback` + +例如:`https://abc123-def456-ghi789.trycloudflare.com/api/wecom/callback` + +#### 3.5 保存并验证 + +1. 点击 **保存** +2. 观察后端日志: + ```bash + docker compose logs backend -f + ``` +3. 应该看到: + ``` + INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} + ``` +4. 企微后台应显示 **保存成功** + +**详细文档**:参见 `docs/cloudflared-setup.md` + +--- + +### 方法 2:使用 ngrok 暴露本地服务 + +#### 2.1 安装 ngrok + +- **Windows**:下载 https://ngrok.com/download,解压到任意目录 +- **或使用包管理器**:`choco install ngrok` 或 `scoop install ngrok` + +#### 2.2 启动 ngrok + +```bash +# 在终端中运行(暴露本地 8000 端口) +ngrok http 8000 + +# 输出示例: +# Forwarding https://abc123.ngrok.io -> http://localhost:8000 +``` + +#### 2.3 获取公网 URL + +从 ngrok 输出中复制 `https://xxx.ngrok.io`,这就是你的公网 URL。 + +**注意**:免费版 ngrok 每次重启 URL 都会变化,需要重新配置企微后台。 + +#### 2.4 配置企微后台 + +在企微后台的回调 URL 中填写:`https://你的ngrok域名.ngrok.io/api/wecom/callback` + +例如:`https://abc123.ngrok.io/api/wecom/callback` + +#### 2.5 保存并验证 + +1. 点击 **保存** +2. 观察后端日志: + ```bash + docker compose logs backend -f + ``` +3. 应该看到: + ``` + INFO: wecom verify success {"trace_id": "...", "echostr_length": 43} + ``` +4. 企微后台应显示 **保存成功** + +--- + +### 方法 2:使用企业微信官方测试工具 + +1. 在企业微信管理后台,进入 **自建应用** → **接收消息** → **调试工具** +2. 输入测试参数,生成测试请求 +3. 使用 curl 或 Postman 发送请求到本地服务 + +--- + +## 四、测试消息回调 + +### 4.1 发送测试消息 + +1. **在企业微信中**: + - 打开企业微信客户端 + - 找到你配置的应用 + - 发送一条文本消息,例如:`你好,测试一下` + +### 4.2 查看后端日志 + +```bash +# 实时查看日志 +docker compose logs backend -f + +# 应该看到以下日志: +``` + +**收到消息日志**: +```json +{ + "timestamp": "2025-02-05T10:00:00Z", + "level": "INFO", + "message": "wecom message received", + "trace_id": "abc123-def456", + "external_userid": "wmxxxxx", + "msgid": "1234567890", + "msg_type": "text", + "content_summary": "你好,测试一下" +} +``` + +**发送回复日志**: +```json +{ + "timestamp": "2025-02-05T10:00:01Z", + "level": "INFO", + "message": "wecom reply sent", + "trace_id": "abc123-def456", + "external_userid": "wmxxxxx", + "msgid": "1234567890", + "reply_summary": "已收到:你好,测试一下" +} +``` + +### 4.3 验证回复消息 + +在企业微信客户端中,应该收到回复:**`已收到:你好,测试一下`** + +### 4.4 检查数据库 + +```bash +# 进入数据库容器 +docker compose exec db psql -U wecom -d wecom_ai + +# 查询会话 +SELECT id, external_user_id, external_name, status, created_at +FROM chat_sessions +ORDER BY created_at DESC +LIMIT 5; + +# 查询消息 +SELECT id, session_id, role, content, created_at +FROM messages +ORDER BY created_at DESC +LIMIT 10; + +# 应该看到: +# - 一条会话记录(external_user_id 为你的企微用户ID) +# - 两条消息记录(一条 user,一条 assistant) +``` + +--- + +## 五、完整测试检查清单 + +### 5.1 配置检查 + +- [ ] `.env` 文件中所有企业微信配置已填写 +- [ ] `WECOM_TOKEN` 与企微后台一致 +- [ ] `WECOM_ENCODING_AES_KEY` 与企微后台一致(43位) +- [ ] `WECOM_CORP_ID` 已填写 +- [ ] 后端服务正常运行 + +### 5.2 GET 校验测试 + +- [ ] 在企微后台点击保存配置 +- [ ] 后端日志显示 `wecom verify success` +- [ ] 企微后台显示 **保存成功** + +### 5.3 POST 回调测试 + +- [ ] 在企业微信中发送文本消息 +- [ ] 后端日志显示 `wecom message received` +- [ ] 后端日志显示 `wecom reply sent` +- [ ] 企业微信中收到回复消息 +- [ ] 数据库中创建了会话记录 +- [ ] 数据库中创建了消息记录(user + assistant) + +--- + +## 六、常见问题排查 + +### 问题 1:GET 校验失败 + +**症状**:企微后台显示"保存失败"或"URL校验失败" + +**排查步骤**: + +1. **检查 Token**: + ```bash + # 查看 .env 中的 WECOM_TOKEN + cat .env | grep WECOM_TOKEN + + # 确保与企微后台完全一致(包括大小写、空格等) + ``` + +2. **检查 EncodingAESKey**: + ```bash + # 查看 .env 中的 WECOM_ENCODING_AES_KEY + cat .env | grep WECOM_ENCODING_AES_KEY + + # 确保是 43 位 Base64 编码(不含等号) + # 确保与企微后台完全一致 + ``` + +3. **检查后端日志**: + ```bash + docker compose logs backend | grep "wecom verify" + + # 如果看到 "wecom verify failed",检查签名或解密错误 + ``` + +4. **检查回调 URL**: + - 确保 URL 可公网访问(使用 ngrok 等工具) + - 确保 URL 格式正确:`https://域名/api/wecom/callback` + - 确保后端服务正常运行 + +**解决方案**: +- 重新检查并同步 Token 和 EncodingAESKey +- 确保回调 URL 可访问 +- 重启后端服务:`docker compose restart backend` + +--- + +### 问题 2:POST 回调失败 + +**症状**:发送消息后没有收到回复,或后端日志显示错误 + +**排查步骤**: + +1. **检查后端日志**: + ```bash + docker compose logs backend | grep -E "wecom|error|Error" + ``` + +2. **检查解密错误**: + - 如果看到 `wecom decrypt error`,检查 `WECOM_ENCODING_AES_KEY` 是否正确 + - 如果看到 `wecom xml parse failed`,检查 XML 格式 + +3. **检查企业 ID**: + ```bash + # 确保 WECOM_CORP_ID 正确 + cat .env | grep WECOM_CORP_ID + ``` + +4. **检查数据库连接**: + ```bash + # 确保数据库服务正常运行 + docker compose ps db + + # 检查数据库连接 + docker compose exec backend python -c "from app.database import engine; print('DB OK')" + ``` + +**解决方案**: +- 检查并修复配置错误 +- 重启后端服务 +- 检查数据库连接 + +--- + +### 问题 3:收到消息但没有回复 + +**症状**:后端日志显示收到消息,但企业微信中没有收到回复 + +**排查步骤**: + +1. **检查回复日志**: + ```bash + docker compose logs backend | grep "wecom reply sent" + + # 如果没有这条日志,说明回复构造或加密失败 + ``` + +2. **检查回复 XML 格式**: + - 查看后端代码中的 `build_reply_xml` 函数 + - 确保 XML 格式符合企业微信规范 + +3. **检查加密**: + - 确保 `WECOM_ENCODING_AES_KEY` 正确 + - 确保 `WECOM_CORP_ID` 正确(用于加密验证) + +**解决方案**: +- 检查回复 XML 构造逻辑 +- 检查加密函数 +- 查看完整错误日志 + +--- + +### 问题 4:URL 变化问题 + +**症状**:每次重启内网穿透工具,URL 都会变化 + +**解决方案**: +- **使用 Cloudflare Tunnel**:登录后可以创建固定域名的 tunnel(推荐) +- **使用 ngrok**:每次重启后需要重新在企微后台更新回调 URL +- **使用 ngrok 付费版**:可以设置固定域名 +- **其他工具**:frp、localtunnel 等(参见 `docs/cloudflared-setup.md`) + +--- + +## 七、高级测试 + +### 7.1 测试不同类型的消息 + +- **文本消息**:发送普通文本 +- **图片消息**:发送图片(当前实现可能只回复"收到") +- **事件消息**:测试关注/取消关注等事件 + +### 7.2 测试并发消息 + +- 同时发送多条消息 +- 检查数据库是否正确记录所有消息 +- 检查回复是否正确 + +### 7.3 测试错误处理 + +- 发送格式错误的消息 +- 检查错误日志 +- 确保服务不会崩溃 + +--- + +## 八、测试完成后 + +### 8.1 验证数据完整性 + +```bash +# 检查会话和消息数据 +docker compose exec db psql -U wecom -d wecom_ai -c " +SELECT + cs.id as session_id, + cs.external_user_id, + cs.external_name, + COUNT(m.id) as message_count, + MAX(m.created_at) as last_message_time +FROM chat_sessions cs +LEFT JOIN messages m ON cs.id = m.session_id +GROUP BY cs.id +ORDER BY cs.created_at DESC; +" +``` + +### 8.2 清理测试数据(可选) + +```bash +# 删除测试数据 +docker compose exec db psql -U wecom -d wecom_ai -c " +DELETE FROM messages; +DELETE FROM chat_sessions; +" +``` + +--- + +## 九、下一步 + +完成阶段 4 测试后,可以继续: + +- **阶段 5**:接入 FAQ 匹配 +- **阶段 6**:接入 RAG 检索 +- **阶段 7**:实现工单转人工流程 + +--- + +## 十、快速测试命令汇总 + +```bash +# 1. 检查服务状态 +docker compose ps + +# 2. 查看后端日志 +docker compose logs backend -f + +# 3. 检查环境变量 +docker compose exec backend env | grep WECOM + +# 4. 启动 Cloudflare Tunnel(推荐) +cloudflared tunnel --url http://localhost:8000 + +# 5. 测试本地健康检查 +curl http://localhost:8000/api/health + +# 6. 测试公网健康检查(替换为你的 cloudflared URL) +curl https://你的域名.trycloudflare.com/api/health + +# 7. 检查数据库 +docker compose exec db psql -U wecom -d wecom_ai -c "SELECT * FROM chat_sessions;" + +# 8. 重启服务 +docker compose restart backend +``` + +**详细 Cloudflare Tunnel 配置**:参见 `docs/cloudflared-setup.md` + +--- + +**提示**:如果遇到问题,请先查看后端日志,大多数问题都会在日志中显示错误信息。 diff --git a/docs/wecom-test.md b/docs/wecom-test.md new file mode 100644 index 0000000..539e76d --- /dev/null +++ b/docs/wecom-test.md @@ -0,0 +1,193 @@ +# 企业微信回调本地测试指南 + +## 前置条件 + +1. 已配置企业微信后台(Token、EncodingAESKey、回调 URL) +2. 已配置 `.env` 文件中的企业微信相关参数 +3. 后端服务已启动:`docker compose up -d backend` + +## 测试方法 + +### 方法 1:使用企业微信官方测试工具(推荐) + +企业微信提供了在线测试工具,可以模拟回调请求: + +1. 访问企业微信管理后台 +2. 进入 **自建应用** → **接收消息** → **调试工具** +3. 输入测试参数,生成测试请求 +4. 使用 curl 或 Postman 发送请求到本地服务 + +### 方法 2:使用 ngrok 暴露本地服务 + +1. **安装 ngrok**:https://ngrok.com/ + +2. **启动 ngrok**: + ```bash + ngrok http 8000 + ``` + +3. **获取公网 URL**:例如 `https://abc123.ngrok.io` + +4. **配置企微后台**: + - 回调 URL:`https://abc123.ngrok.io/api/wecom/callback` + - Token 和 EncodingAESKey 与 `.env` 一致 + +5. **保存配置**:企微会自动发送 GET 请求校验 + +6. **发送测试消息**:在企业微信中向应用发送消息 + +### 方法 3:手动构造测试请求(高级) + +**注意**:需要真实的签名和加密数据,通常从企微后台的实际请求中获取。 + +#### GET 请求测试 + +```bash +curl -X GET "http://localhost:8000/api/wecom/callback?msg_signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx" +``` + +**预期响应**:返回解密后的 `echostr` 明文 + +#### POST 请求测试 + +```bash +curl -X POST "http://localhost:8000/api/wecom/callback?msg_signature=xxx×tamp=xxx&nonce=xxx" \ + -H "Content-Type: application/xml" \ + -d '' +``` + +**预期响应**:返回加密后的回复 XML + +## 验证步骤 + +### 1. 检查 GET 校验日志 + +```bash +docker compose logs backend | grep "wecom verify" +``` + +**成功日志示例**: +```json +{ + "level": "INFO", + "message": "wecom verify success", + "trace_id": "abc123", + "echostr_length": 43 +} +``` + +### 2. 检查 POST 回调日志 + +```bash +docker compose logs backend | grep "wecom message" +``` + +**成功日志示例**: +```json +{ + "level": "INFO", + "message": "wecom message received", + "trace_id": "abc123", + "external_userid": "wmxxxxx", + "msgid": "1234567890", + "msg_type": "text", + "content_summary": "你好,我想咨询一下..." +} +``` + +```json +{ + "level": "INFO", + "message": "wecom reply sent", + "trace_id": "abc123", + "external_userid": "wmxxxxx", + "msgid": "1234567890", + "reply_summary": "已收到:你好,我想咨询一下..." +} +``` + +### 3. 检查数据库 + +```bash +# 进入数据库容器 +docker compose exec db psql -U wecom -d wecom_ai + +# 查询会话 +SELECT id, external_user_id, status, created_at FROM chat_sessions; + +# 查询消息 +SELECT id, session_id, role, content, created_at FROM messages ORDER BY created_at DESC LIMIT 10; +``` + +### 4. 在企业微信中验证 + +- 发送消息后,应收到回复:`已收到:{你发送的消息}` +- 如果未收到回复,检查后端日志中的错误信息 + +## 常见错误排查 + +### 错误 1:GET 校验失败 + +**日志**:`wecom verify failed` + +**可能原因**: +- Token 不匹配 +- EncodingAESKey 不匹配 +- 签名计算错误 + +**解决方法**: +1. 检查 `.env` 中的 `WECOM_TOKEN` 是否与企微后台一致 +2. 检查 `.env` 中的 `WECOM_ENCODING_AES_KEY` 是否与企微后台一致(43 位,不含等号) +3. 确认回调 URL 可公网访问 + +### 错误 2:POST 解密失败 + +**日志**:`wecom decrypt error` + +**可能原因**: +- EncodingAESKey 错误 +- 加密数据格式错误 +- 企业 ID 不匹配 + +**解决方法**: +1. 检查 `WECOM_ENCODING_AES_KEY` 是否正确 +2. 检查 `WECOM_CORP_ID` 是否正确(用于验证解密后的企业 ID) + +### 错误 3:XML 解析失败 + +**日志**:`wecom xml parse failed` + +**可能原因**: +- XML 格式错误 +- 字符编码问题 + +**解决方法**: +1. 检查解密后的 XML 格式是否正确 +2. 确认 XML 使用 UTF-8 编码 + +### 错误 4:回复未收到 + +**可能原因**: +- 回复 XML 格式错误 +- 回复加密错误 +- 网络问题 + +**解决方法**: +1. 检查日志中是否有 `wecom reply sent` 记录 +2. 检查回复 XML 格式是否正确 +3. 检查加密后的回复是否正确 + +## 测试检查清单 + +- [ ] GET 校验成功(企微后台显示"保存成功") +- [ ] POST 回调日志正常(收到消息和发送回复都有日志) +- [ ] 数据库中有会话和消息记录 +- [ ] 企业微信中能收到回复消息 +- [ ] 日志中包含 trace_id、external_userid、msgid、content_summary + +## 下一步 + +完成阶段 4 测试后,可以继续: +- 阶段 5:接入 FAQ 匹配 +- 阶段 6:接入 RAG 检索 +- 阶段 7:实现工单转人工流程 diff --git a/docs/wecom.md b/docs/wecom.md new file mode 100644 index 0000000..50b1a03 --- /dev/null +++ b/docs/wecom.md @@ -0,0 +1,243 @@ +# 企业微信回调配置与联调说明 + +## 一、企业微信后台配置 + +### 1. 获取必要参数 + +在企业微信管理后台(https://work.weixin.qq.com)获取以下参数: + +| 参数 | 获取位置 | 说明 | +|------|---------|------| +| **企业 ID (CorpId)** | 我的企业 → 企业信息 | 企业唯一标识 | +| **Token** | 自建应用 → 接收消息 → 设置 | 自定义字符串,用于签名校验 | +| **EncodingAESKey** | 自建应用 → 接收消息 → 设置 | 43 位 Base64 编码的 AES 密钥(自动生成或手动输入) | +| **AgentId** | 自建应用 → 应用详情 | 应用 ID | +| **Secret** | 自建应用 → 应用详情 | 应用密钥(用于主动发送消息,当前阶段可选) | + +### 2. 配置回调 URL + +1. 进入 **自建应用** → 选择你的应用 → **接收消息** +2. 点击 **设置 API 接收** +3. 填写以下信息: + - **接收消息服务器 URL**:`https://你的域名/api/wecom/callback` + - **Token**:填写你自定义的 Token(需与 `.env` 中的 `WECOM_TOKEN` 一致) + - **EncodingAESKey**:填写 43 位密钥(需与 `.env` 中的 `WECOM_ENCODING_AES_KEY` 一致) + - **消息加解密方式**:选择 **安全模式**(推荐)或 **明文模式**(仅用于测试) + +### 3. 保存配置 + +点击 **保存** 后,企业微信会立即发送 GET 请求到你的回调 URL 进行校验。如果配置正确,会显示 **保存成功**。 + +--- + +## 二、本地环境变量配置 + +在 `.env` 文件中配置以下变量: + +```bash +# 企业微信配置 +WECOM_CORP_ID=你的企业ID +WECOM_AGENT_ID=你的应用AgentId +WECOM_SECRET=你的应用Secret(可选,当前阶段用于主动发送消息) +WECOM_TOKEN=你的Token(需与企微后台一致) +WECOM_ENCODING_AES_KEY=你的43位EncodingAESKey(需与企微后台一致) +WECOM_API_BASE=https://qyapi.weixin.qq.com +WECOM_API_TIMEOUT=10 +WECOM_API_RETRIES=2 +``` + +**重要**: +- `WECOM_TOKEN` 和 `WECOM_ENCODING_AES_KEY` 必须与企微后台配置完全一致 +- `WECOM_CORP_ID` 用于加密回复时验证企业身份 + +--- + +## 三、回调机制说明 + +### GET 请求(URL 校验) + +企业微信在保存配置时会发送 GET 请求: + +``` +GET /api/wecom/callback?msg_signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx +``` + +**处理流程**: +1. 使用 `msg_signature`、`timestamp`、`nonce`、`echostr` 验签 +2. 解密 `echostr` 得到明文 +3. 将明文原样返回 + +**成功响应**:返回解密后的 `echostr` 明文(纯文本) + +### POST 请求(消息回调) + +客户发送消息时,企业微信会发送 POST 请求: + +``` +POST /api/wecom/callback?msg_signature=xxx×tamp=xxx&nonce=xxx +Content-Type: application/xml + + + + +``` + +**处理流程**: +1. 解析 XML,提取 `Encrypt` 节点 +2. 使用 `msg_signature`、`timestamp`、`nonce`、`Encrypt` 验签 +3. 解密 `Encrypt` 得到消息 XML +4. 解析消息(`MsgType`、`Content`、`FromUserName`、`MsgId` 等) +5. 记录日志(trace_id + external_userid + msgid + 内容摘要) +6. 创建/更新会话,保存消息到数据库 +7. 构造回复 XML(当前为 echo:`已收到:{用户消息}`) +8. 加密回复 XML +9. 返回加密后的 XML 响应 + +**成功响应**:返回加密后的 XML(被动回复) + +```xml + + + + 时间戳 + + +``` + +--- + +## 四、加解密说明 + +### 安全模式(推荐) + +- **加密算法**:AES-256-CBC +- **填充方式**:PKCS7 +- **签名算法**:SHA1 + +**加密格式**: +``` +[16字节随机数][4字节消息长度][消息内容][企业ID][PKCS7填充] +``` + +**解密流程**: +1. Base64 解码 +2. AES-CBC 解密 +3. 提取消息长度(第 16-20 字节) +4. 按长度提取消息内容 +5. 验证尾部企业 ID + +### 明文模式(仅测试) + +如果选择明文模式,企业微信会直接发送未加密的 XML,但**不推荐用于生产环境**。 + +**切换到明文模式**: +1. 在企微后台将 **消息加解密方式** 改为 **明文模式** +2. 代码中需要修改 `wecom.py`,跳过解密步骤,直接解析 XML(当前实现仅支持安全模式) + +--- + +## 五、本地联调测试 + +### 1. 启动服务 + +```bash +# 确保后端服务运行 +docker compose up -d backend + +# 查看日志 +docker compose logs backend -f +``` + +### 2. 使用 curl 测试 GET 校验(模拟企微) + +**注意**:实际测试需要真实的签名和加密数据。以下为示例格式: + +```bash +# 需要先获取真实的签名和 echostr(从企微后台保存配置时的请求中获取) +curl -X GET "http://localhost:8000/api/wecom/callback?msg_signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx" +``` + +**预期响应**:返回解密后的 `echostr` 明文 + +### 3. 使用 curl 测试 POST 回调(模拟企微) + +**注意**:需要真实的加密 XML。可以从企微后台的实际回调请求中获取,或使用企业微信官方测试工具。 + +```bash +# 示例格式(需要替换为真实的加密 XML) +curl -X POST "http://localhost:8000/api/wecom/callback?msg_signature=xxx×tamp=xxx&nonce=xxx" \ + -H "Content-Type: application/xml" \ + -d '' +``` + +**预期响应**:返回加密后的回复 XML + +### 4. 真实环境测试 + +1. **配置回调 URL**:将你的公网域名配置到企微后台(如使用 ngrok 等工具暴露本地服务) +2. **保存配置**:在企微后台点击保存,观察 GET 请求是否成功 +3. **发送测试消息**:在企业微信中向应用发送一条文本消息 +4. **查看日志**:检查后端日志,应看到: + ``` + INFO: wecom message received {"trace_id": "...", "external_userid": "...", "msgid": "...", "content_summary": "..."} + INFO: wecom reply sent {"trace_id": "...", "external_userid": "...", "msgid": "...", "reply_summary": "..."} + ``` +5. **验证回复**:在企业微信中应收到回复消息:`已收到:{你发送的消息}` + +--- + +## 六、日志格式 + +所有回调请求都会记录结构化日志,包含以下字段: + +- **trace_id**:请求追踪 ID +- **external_userid**:外部用户 ID(客户 ID) +- **msgid**:消息 ID +- **msg_type**:消息类型(text/image/event 等) +- **content_summary**:消息内容摘要(前 50 字符) +- **reply_summary**:回复内容摘要(前 50 字符) + +**示例日志**: +```json +{ + "timestamp": "2025-02-05T10:00:00Z", + "level": "INFO", + "message": "wecom message received", + "trace_id": "abc123", + "external_userid": "wmxxxxx", + "msgid": "1234567890", + "msg_type": "text", + "content_summary": "你好,我想咨询一下产品信息..." +} +``` + +--- + +## 七、常见问题 + +### 1. GET 校验失败 + +- **检查 Token**:确保 `.env` 中的 `WECOM_TOKEN` 与企微后台一致 +- **检查 EncodingAESKey**:确保 `.env` 中的 `WECOM_ENCODING_AES_KEY` 与企微后台一致(43 位,不含等号) +- **检查 URL**:确保回调 URL 可公网访问 + +### 2. POST 回调失败 + +- **检查签名**:确保 `msg_signature` 校验通过 +- **检查解密**:确保 `WECOM_ENCODING_AES_KEY` 正确 +- **检查企业 ID**:确保 `WECOM_CORP_ID` 正确(用于验证解密后的企业 ID) + +### 3. 回复未收到 + +- **检查日志**:查看是否有错误日志 +- **检查数据库**:确认消息是否已入库 +- **检查 XML 格式**:确保回复 XML 格式正确 +- **检查加密**:确保回复加密正确 + +--- + +## 八、下一步(阶段 5+) + +- 接入企业微信主动发送消息 API(需要 `WECOM_SECRET` 获取 access_token) +- 实现 FAQ 匹配和 RAG 检索 +- 实现工单转人工流程 diff --git a/install-cloudflared.ps1 b/install-cloudflared.ps1 new file mode 100644 index 0000000..d65233f --- /dev/null +++ b/install-cloudflared.ps1 @@ -0,0 +1,80 @@ +# Auto download and install cloudflared + +Write-Host "=== Cloudflare Tunnel Installer ===" -ForegroundColor Cyan +Write-Host "" + +# Check if already installed +$cloudflaredPath = Get-Command cloudflared -ErrorAction SilentlyContinue +if ($cloudflaredPath) { + Write-Host "cloudflared is already installed" -ForegroundColor Green + cloudflared --version + exit 0 +} + +Write-Host "[1/3] Downloading cloudflared MSI..." -ForegroundColor Yellow + +# Download URL +$downloadUrl = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.msi" +$downloadPath = "$env:TEMP\cloudflared-windows-amd64.msi" + +try { + # Download file + Write-Host "Downloading from GitHub..." -ForegroundColor Cyan + Invoke-WebRequest -Uri $downloadUrl -OutFile $downloadPath -UseBasicParsing + + if (Test-Path $downloadPath) { + Write-Host "Download completed: $downloadPath" -ForegroundColor Green + Write-Host "" + + Write-Host "[2/3] Installing cloudflared..." -ForegroundColor Yellow + Write-Host "Starting installer..." -ForegroundColor Cyan + + # Install using msiexec + $installArgs = "/i `"$downloadPath`" /quiet /norestart" + Start-Process -FilePath "msiexec.exe" -ArgumentList $installArgs -Wait -NoNewWindow + + Write-Host "Installation completed" -ForegroundColor Green + Write-Host "" + + # Clean up + Remove-Item $downloadPath -Force -ErrorAction SilentlyContinue + + Write-Host "[3/3] Verifying installation..." -ForegroundColor Yellow + + # Refresh PATH + $machinePath = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [System.Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = "$machinePath;$userPath" + + # Wait for PATH update + Start-Sleep -Seconds 3 + + # Check installation + $cloudflaredPath = Get-Command cloudflared -ErrorAction SilentlyContinue + if ($cloudflaredPath) { + Write-Host "cloudflared installed successfully!" -ForegroundColor Green + Write-Host "" + cloudflared --version + Write-Host "" + Write-Host "=== Next Step ===" -ForegroundColor Cyan + Write-Host "Run this command to start tunnel:" -ForegroundColor Yellow + Write-Host " cloudflared tunnel --url http://localhost:8000" -ForegroundColor White + } else { + Write-Host "Installation completed, but you may need to restart PowerShell" -ForegroundColor Yellow + Write-Host "Please close and reopen PowerShell, then run: cloudflared --version" -ForegroundColor Yellow + } + } else { + Write-Host "Download failed" -ForegroundColor Red + Write-Host "Please download manually:" -ForegroundColor Yellow + Write-Host " $downloadUrl" -ForegroundColor Cyan + exit 1 + } +} catch { + Write-Host "Download or installation failed: $_" -ForegroundColor Red + Write-Host "" + Write-Host "Please install manually:" -ForegroundColor Yellow + Write-Host "1. Visit: https://github.com/cloudflare/cloudflared/releases/latest" -ForegroundColor Cyan + Write-Host "2. Download: cloudflared-windows-amd64.msi" -ForegroundColor Cyan + Write-Host "3. Double-click to install" -ForegroundColor Cyan + exit 1 +} diff --git a/scripts/push-to-github.ps1 b/scripts/push-to-github.ps1 new file mode 100644 index 0000000..e1762fd --- /dev/null +++ b/scripts/push-to-github.ps1 @@ -0,0 +1,90 @@ +# 快速推送代码到 GitHub +# 用途:使用配置文件中的信息自动推送代码 + +param( + [string]$CommitMessage = "Update: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')", + [string]$ConfigFile = ".github-config", + [switch]$Force +) + +Write-Host "=== 推送代码到 GitHub ===" -ForegroundColor Cyan +Write-Host "" + +# 检查配置文件 +if (-not (Test-Path $ConfigFile)) { + Write-Host "错误: 配置文件 $ConfigFile 不存在" -ForegroundColor Red + Write-Host "请先运行: .\scripts\setup-github-from-config.ps1" -ForegroundColor Yellow + exit 1 +} + +# 读取配置 +$config = @{} +Get-Content $ConfigFile | ForEach-Object { + if ($_ -match '^\s*([^#=]+?)\s*=\s*(.+?)\s*$') { + $key = $matches[1].Trim() + $value = $matches[2].Trim() + $config[$key] = $value + } +} + +$repoUrl = $config['GITHUB_REPO_URL'] +$defaultBranch = $config['GITHUB_DEFAULT_BRANCH'] +if (-not $defaultBranch) { + $defaultBranch = "main" +} + +# 检查 Git 状态 +Write-Host "检查 Git 状态..." -ForegroundColor Yellow +$status = git status --porcelain 2>$null +if ($status) { + Write-Host "发现更改的文件:" -ForegroundColor Cyan + git status --short + Write-Host "" + + # 添加所有更改 + Write-Host "添加所有更改..." -ForegroundColor Yellow + git add . + Write-Host "✓ 文件已添加到暂存区" -ForegroundColor Green + + # 提交 + Write-Host "提交更改..." -ForegroundColor Yellow + Write-Host "提交信息: $CommitMessage" -ForegroundColor Gray + git commit -m $CommitMessage + Write-Host "✓ 更改已提交" -ForegroundColor Green +} else { + Write-Host "✓ 没有需要提交的更改" -ForegroundColor Green +} + +# 检查远程仓库 +$currentRemote = git remote get-url origin 2>$null +if (-not $currentRemote) { + Write-Host "错误: 未配置远程仓库" -ForegroundColor Red + Write-Host "请先运行: .\scripts\setup-github-from-config.ps1" -ForegroundColor Yellow + exit 1 +} + +# 推送 +Write-Host "" +Write-Host "推送到 GitHub..." -ForegroundColor Yellow +Write-Host "远程仓库: $currentRemote" -ForegroundColor Gray +Write-Host "分支: $defaultBranch" -ForegroundColor Gray +Write-Host "" + +if ($Force) { + Write-Host "⚠ 使用 --force 推送(危险操作)" -ForegroundColor Red + git push -u origin $defaultBranch --force +} else { + git push -u origin $defaultBranch +} + +if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "✓ 代码已成功推送到 GitHub" -ForegroundColor Green + Write-Host "" + Write-Host "查看仓库: $repoUrl" -ForegroundColor Cyan + Write-Host "GitHub Actions: $repoUrl/actions" -ForegroundColor Cyan +} else { + Write-Host "" + Write-Host "✗ 推送失败,请检查错误信息" -ForegroundColor Red + exit 1 +} diff --git a/scripts/setup-github-from-config.ps1 b/scripts/setup-github-from-config.ps1 new file mode 100644 index 0000000..8a9c618 --- /dev/null +++ b/scripts/setup-github-from-config.ps1 @@ -0,0 +1,162 @@ +# 从配置文件自动设置 GitHub 仓库 +# 用途:读取 .github-config 文件并配置 Git 远程仓库 + +param( + [string]$ConfigFile = ".github-config" +) + +Write-Host "=== 从配置文件设置 GitHub 仓库 ===" -ForegroundColor Cyan +Write-Host "" + +# 检查配置文件是否存在 +if (-not (Test-Path $ConfigFile)) { + Write-Host "错误: 配置文件 $ConfigFile 不存在" -ForegroundColor Red + Write-Host "请先复制 .github-config.example 为 .github-config 并填写配置" -ForegroundColor Yellow + exit 1 +} + +# 读取配置文件 +Write-Host "读取配置文件: $ConfigFile" -ForegroundColor Yellow +$config = @{} +Get-Content $ConfigFile | ForEach-Object { + if ($_ -match '^\s*([^#=]+?)\s*=\s*(.+?)\s*$') { + $key = $matches[1].Trim() + $value = $matches[2].Trim() + $config[$key] = $value + } +} + +# 验证必需配置 +$requiredKeys = @('GITHUB_USERNAME', 'GITHUB_TOKEN', 'GITHUB_REPO_NAME', 'GITHUB_REPO_URL') +foreach ($key in $requiredKeys) { + if (-not $config.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($config[$key])) { + Write-Host "错误: 配置文件缺少必需的键: $key" -ForegroundColor Red + exit 1 + } +} + +# 显示配置信息(隐藏 token) +Write-Host "配置信息:" -ForegroundColor Cyan +Write-Host " GitHub 用户名: $($config['GITHUB_USERNAME'])" -ForegroundColor Gray +Write-Host " GitHub Token: $($config['GITHUB_TOKEN'].Substring(0, [Math]::Min(10, $config['GITHUB_TOKEN'].Length)))..." -ForegroundColor Gray +Write-Host " 仓库名称: $($config['GITHUB_REPO_NAME'])" -ForegroundColor Gray +Write-Host " 仓库 URL: $($config['GITHUB_REPO_URL'])" -ForegroundColor Gray +Write-Host "" + +# 检查 Git 是否已初始化 +if (-not (Test-Path .git)) { + Write-Host "初始化 Git 仓库..." -ForegroundColor Yellow + git init + Write-Host "✓ Git 仓库已初始化" -ForegroundColor Green +} else { + Write-Host "✓ Git 仓库已存在" -ForegroundColor Green +} + +# 配置 Git 用户信息(如果还没有) +$gitUser = git config user.name 2>$null +$gitEmail = git config user.email 2>$null + +if (-not $gitUser) { + Write-Host "" + Write-Host "配置 Git 用户信息..." -ForegroundColor Yellow + $inputUser = Read-Host "请输入 Git 用户名 (或按 Enter 使用 GitHub 用户名: $($config['GITHUB_USERNAME']))" + if ([string]::IsNullOrWhiteSpace($inputUser)) { + $inputUser = $config['GITHUB_USERNAME'] + } + git config user.name $inputUser + Write-Host "✓ Git 用户名已设置: $inputUser" -ForegroundColor Green +} + +if (-not $gitEmail) { + $inputEmail = Read-Host "请输入 Git 邮箱" + if (-not [string]::IsNullOrWhiteSpace($inputEmail)) { + git config user.email $inputEmail + Write-Host "✓ Git 邮箱已设置: $inputEmail" -ForegroundColor Green + } +} + +# 配置远程仓库 +Write-Host "" +Write-Host "配置远程仓库..." -ForegroundColor Yellow + +$currentRemote = git remote get-url origin 2>$null +if ($currentRemote) { + Write-Host "当前远程仓库: $currentRemote" -ForegroundColor Cyan + if ($currentRemote -ne $config['GITHUB_REPO_URL']) { + $update = Read-Host "是否更新远程仓库地址? (y/n)" + if ($update -eq "y" -or $update -eq "Y") { + git remote set-url origin $config['GITHUB_REPO_URL'] + Write-Host "✓ 远程仓库已更新" -ForegroundColor Green + } + } else { + Write-Host "✓ 远程仓库地址已正确" -ForegroundColor Green + } +} else { + git remote add origin $config['GITHUB_REPO_URL'] + Write-Host "✓ 远程仓库已添加" -ForegroundColor Green +} + +# 配置 Git 凭据(使用 token) +Write-Host "" +Write-Host "配置 Git 凭据..." -ForegroundColor Yellow + +# 设置 Git 凭据助手(Windows) +$credentialHelper = git config credential.helper 2>$null +if (-not $credentialHelper) { + git config credential.helper manager-core + Write-Host "✓ Git 凭据助手已配置" -ForegroundColor Green +} + +# 提取仓库 URL 的用户名和密码部分 +$repoUrl = $config['GITHUB_REPO_URL'] +if ($repoUrl -match 'https://(.+?)@') { + Write-Host "检测到 URL 中已包含凭据" -ForegroundColor Gray +} else { + # 将 token 添加到 URL 中(用于推送) + $urlWithToken = $repoUrl -replace 'https://', "https://$($config['GITHUB_USERNAME']):$($config['GITHUB_TOKEN'])@" + git remote set-url origin $urlWithToken + Write-Host "✓ Git 凭据已配置到远程 URL" -ForegroundColor Green +} + +# 设置默认分支 +Write-Host "" +Write-Host "设置默认分支..." -ForegroundColor Yellow +$defaultBranch = $config['GITHUB_DEFAULT_BRANCH'] +if (-not $defaultBranch) { + $defaultBranch = "main" +} + +$currentBranch = git branch --show-current 2>$null +if (-not $currentBranch) { + # 创建初始提交(如果还没有) + Write-Host "创建初始提交..." -ForegroundColor Yellow + git add . + git commit -m "Initial commit: 企业微信 AI 助手" 2>$null + git branch -M $defaultBranch + Write-Host "✓ 已创建初始提交并设置分支为 $defaultBranch" -ForegroundColor Green +} else { + if ($currentBranch -ne $defaultBranch) { + git branch -M $defaultBranch + Write-Host "✓ 分支已重命名为 $defaultBranch" -ForegroundColor Green + } +} + +# 显示下一步操作 +Write-Host "" +Write-Host "=== 设置完成 ===" -ForegroundColor Green +Write-Host "" +Write-Host "下一步操作:" -ForegroundColor Cyan +Write-Host "" +Write-Host "1. 检查远程仓库配置:" -ForegroundColor Yellow +Write-Host " git remote -v" -ForegroundColor Gray +Write-Host "" +Write-Host "2. 推送代码到 GitHub:" -ForegroundColor Yellow +Write-Host " git push -u origin $defaultBranch" -ForegroundColor Gray +Write-Host "" +Write-Host "3. 配置 GitHub Secrets (用于 GitHub Actions):" -ForegroundColor Yellow +Write-Host " 进入仓库: https://github.com/$($config['GITHUB_USERNAME'])/$($config['GITHUB_REPO_NAME'])/settings/secrets/actions" -ForegroundColor Gray +Write-Host " 添加 Secrets: PROD_HOST, PROD_USER, PROD_SSH_KEY, PROD_DOMAIN" -ForegroundColor Gray +Write-Host "" +Write-Host "4. 查看详细文档:" -ForegroundColor Yellow +Write-Host " docs/github-quickstart.md" -ForegroundColor Gray +Write-Host "" diff --git a/scripts/setup-github.ps1 b/scripts/setup-github.ps1 new file mode 100644 index 0000000..70ea354 --- /dev/null +++ b/scripts/setup-github.ps1 @@ -0,0 +1,162 @@ +# GitHub Actions 部署快速设置脚本 +# 用途:生成 SSH 密钥、准备 GitHub 推送 + +Write-Host "=== GitHub Actions 部署快速设置 ===" -ForegroundColor Cyan +Write-Host "" + +# 检查 Git 是否已初始化 +if (-not (Test-Path .git)) { + Write-Host "初始化 Git 仓库..." -ForegroundColor Yellow + git init + Write-Host "✓ Git 仓库已初始化" -ForegroundColor Green +} else { + Write-Host "✓ Git 仓库已存在" -ForegroundColor Green +} + +# 检查是否已配置远程仓库 +$remoteUrl = git remote get-url origin 2>$null +if ($remoteUrl) { + Write-Host "" + Write-Host "当前远程仓库: $remoteUrl" -ForegroundColor Cyan + $changeRemote = Read-Host "是否更改远程仓库地址? (y/n)" + if ($changeRemote -eq "y" -or $changeRemote -eq "Y") { + $newUrl = Read-Host "请输入新的 GitHub 仓库 URL" + git remote set-url origin $newUrl + Write-Host "✓ 远程仓库已更新" -ForegroundColor Green + } +} else { + Write-Host "" + Write-Host "未配置远程仓库" -ForegroundColor Yellow + $setupRemote = Read-Host "是否现在配置? (y/n)" + if ($setupRemote -eq "y" -or $setupRemote -eq "Y") { + $githubUrl = Read-Host "请输入 GitHub 仓库 URL (例如: https://github.com/username/repo.git)" + git remote add origin $githubUrl + Write-Host "✓ 远程仓库已添加" -ForegroundColor Green + } +} + +# 生成 SSH 密钥 +Write-Host "" +Write-Host "=== 生成 SSH 密钥 ===" -ForegroundColor Cyan +$sshKeyPath = "$env:USERPROFILE\.ssh\github-actions" +$sshKeyPubPath = "$sshKeyPath.pub" + +if (Test-Path $sshKeyPath) { + Write-Host "SSH 密钥已存在: $sshKeyPath" -ForegroundColor Yellow + $regenerate = Read-Host "是否重新生成? (y/n)" + if ($regenerate -ne "y" -and $regenerate -ne "Y") { + Write-Host "跳过 SSH 密钥生成" -ForegroundColor Gray + } else { + Remove-Item $sshKeyPath -Force -ErrorAction SilentlyContinue + Remove-Item $sshKeyPubPath -Force -ErrorAction SilentlyContinue + } +} + +if (-not (Test-Path $sshKeyPath)) { + Write-Host "正在生成 SSH 密钥..." -ForegroundColor Yellow + ssh-keygen -t ed25519 -C "github-actions-deploy" -f $sshKeyPath -N '""' | Out-Null + Write-Host "✓ SSH 密钥已生成" -ForegroundColor Green +} + +# 显示公钥和私钥 +Write-Host "" +Write-Host "=== SSH 密钥信息 ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "公钥(需要添加到服务器的 ~/.ssh/authorized_keys):" -ForegroundColor Yellow +Write-Host "---" +Get-Content $sshKeyPubPath +Write-Host "---" +Write-Host "" + +Write-Host "私钥(需要添加到 GitHub Secrets 的 PROD_SSH_KEY):" -ForegroundColor Yellow +Write-Host "---" +Get-Content $sshKeyPath +Write-Host "---" +Write-Host "" + +# 保存到文件 +$pubKeyFile = "github-actions.pub" +$privKeyFile = "github-actions.key" +Copy-Item $sshKeyPubPath $pubKeyFile -Force +Copy-Item $sshKeyPath $privKeyFile -Force +Write-Host "✓ 密钥已保存到项目根目录:" -ForegroundColor Green +Write-Host " - $pubKeyFile (公钥)" -ForegroundColor Gray +Write-Host " - $privKeyFile (私钥)" -ForegroundColor Gray +Write-Host "" +Write-Host "⚠ 注意: 请妥善保管私钥文件,不要提交到 Git!" -ForegroundColor Red + +# 检查 .gitignore +Write-Host "" +Write-Host "=== 检查 .gitignore ===" -ForegroundColor Cyan +if (Test-Path .gitignore) { + $gitignoreContent = Get-Content .gitignore -Raw + if ($gitignoreContent -notmatch "github-actions\.key") { + Add-Content .gitignore "`n# GitHub Actions SSH Key`ngithub-actions.key`n" + Write-Host "✓ 已添加 github-actions.key 到 .gitignore" -ForegroundColor Green + } else { + Write-Host "✓ .gitignore 已包含 github-actions.key" -ForegroundColor Green + } +} else { + Write-Host "创建 .gitignore..." -ForegroundColor Yellow + @" +# GitHub Actions SSH Key +github-actions.key +"@ | Out-File .gitignore -Encoding UTF8 + Write-Host "✓ .gitignore 已创建" -ForegroundColor Green +} + +# 生成 GitHub Secrets 配置模板 +Write-Host "" +Write-Host "=== GitHub Secrets 配置模板 ===" -ForegroundColor Cyan +$secretsTemplate = @" +# GitHub Secrets 配置清单 +# 进入仓库: Settings → Secrets and variables → Actions → New repository secret + +## 必需 Secrets + +PROD_HOST=你的服务器IP +PROD_USER=你的SSH用户名(通常是 root 或 ubuntu) +PROD_SSH_KEY=上面的私钥内容(github-actions.key 文件内容) +PROD_DOMAIN=你的生产域名(例如: api.yourdomain.com) + +## 可选 Secrets + +PROD_SSH_PORT=22 +PROD_APP_PATH=/opt/wecom-ai-assistant +GHCR_TOKEN=(可选,默认使用 GITHUB_TOKEN) + +## 配置步骤 + +1. 复制上面的私钥内容 +2. 进入 GitHub 仓库 → Settings → Secrets and variables → Actions +3. 点击 New repository secret +4. 依次添加上述 Secrets(名称和值) +5. 确保 Workflow permissions 设置为 "Read and write permissions" +"@ + +$secretsFile = "GITHUB_SECRETS_TEMPLATE.md" +$secretsTemplate | Out-File $secretsFile -Encoding UTF8 +Write-Host "✓ 配置模板已保存到: $secretsFile" -ForegroundColor Green + +# 显示下一步操作 +Write-Host "" +Write-Host "=== 下一步操作 ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "1. 将公钥添加到服务器:" -ForegroundColor Yellow +Write-Host " ssh user@your-server" -ForegroundColor Gray +Write-Host " mkdir -p ~/.ssh" -ForegroundColor Gray +Write-Host " echo '$(Get-Content $pubKeyFile)' >> ~/.ssh/authorized_keys" -ForegroundColor Gray +Write-Host " chmod 600 ~/.ssh/authorized_keys" -ForegroundColor Gray +Write-Host "" +Write-Host "2. 配置 GitHub Secrets:" -ForegroundColor Yellow +Write-Host " 查看文件: $secretsFile" -ForegroundColor Gray +Write-Host "" +Write-Host "3. 推送代码到 GitHub:" -ForegroundColor Yellow +Write-Host " git add ." -ForegroundColor Gray +Write-Host " git commit -m 'Initial commit'" -ForegroundColor Gray +Write-Host " git push -u origin main" -ForegroundColor Gray +Write-Host "" +Write-Host "4. 在生产服务器上准备:" -ForegroundColor Yellow +Write-Host " 参见: docs/github-quickstart.md" -ForegroundColor Gray +Write-Host "" +Write-Host "✓ 设置完成!" -ForegroundColor Green diff --git a/setup-cloudflared.ps1 b/setup-cloudflared.ps1 new file mode 100644 index 0000000..20a8f1d --- /dev/null +++ b/setup-cloudflared.ps1 @@ -0,0 +1,66 @@ +# Cloudflare Tunnel 安装和启动脚本 + +Write-Host "=== Cloudflare Tunnel 设置 ===" -ForegroundColor Cyan + +# 检查是否已安装 +$cloudflaredPath = Get-Command cloudflared -ErrorAction SilentlyContinue + +if (-not $cloudflaredPath) { + Write-Host "`n[1/4] cloudflared 未安装,开始安装..." -ForegroundColor Yellow + + # 检查是否有 Scoop + $scoopPath = Get-Command scoop -ErrorAction SilentlyContinue + + if ($scoopPath) { + Write-Host "检测到 Scoop,使用 Scoop 安装..." -ForegroundColor Green + scoop install cloudflared + } else { + Write-Host "未检测到 Scoop,请手动安装 cloudflared:" -ForegroundColor Yellow + Write-Host "1. 下载:https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.msi" -ForegroundColor Cyan + Write-Host "2. 双击安装 MSI 文件" -ForegroundColor Cyan + Write-Host "3. 重新运行此脚本" -ForegroundColor Cyan + exit 1 + } +} else { + Write-Host "`n[1/4] ✓ cloudflared 已安装" -ForegroundColor Green + cloudflared --version +} + +# 检查 Docker 服务 +Write-Host "`n[2/4] 检查 Docker 服务..." -ForegroundColor Yellow +$backendStatus = docker compose ps backend --format json | ConvertFrom-Json | Select-Object -ExpandProperty State + +if ($backendStatus -ne "running") { + Write-Host "后端服务未运行,正在启动..." -ForegroundColor Yellow + docker compose up -d backend + Start-Sleep -Seconds 5 +} + +Write-Host "✓ Docker 服务运行正常" -ForegroundColor Green + +# 测试本地服务 +Write-Host "`n[3/4] 测试本地服务..." -ForegroundColor Yellow +try { + $response = Invoke-WebRequest -Uri "http://localhost:8000/api/health" -UseBasicParsing -TimeoutSec 5 + if ($response.StatusCode -eq 200) { + Write-Host "✓ 本地服务正常 (http://localhost:8000)" -ForegroundColor Green + } +} catch { + Write-Host "✗ 本地服务无法访问,请检查后端服务" -ForegroundColor Red + Write-Host "运行: docker compose logs backend" -ForegroundColor Yellow + exit 1 +} + +# 启动 cloudflared +Write-Host "`n[4/4] 启动 Cloudflare Tunnel..." -ForegroundColor Yellow +Write-Host "正在启动 tunnel,请稍候..." -ForegroundColor Cyan +Write-Host "`n=== 重要提示 ===" -ForegroundColor Yellow +Write-Host "1. 下方会显示公网 URL(例如:https://xxx.trycloudflare.com)" -ForegroundColor White +Write-Host "2. 复制这个 URL,用于配置企业微信回调" -ForegroundColor White +Write-Host "3. 回调 URL 格式:https://你的域名.trycloudflare.com/api/wecom/callback" -ForegroundColor White +Write-Host "4. 保持此窗口运行,不要关闭" -ForegroundColor White +Write-Host "`n按 Ctrl+C 停止 tunnel`n" -ForegroundColor Yellow +Write-Host "=" * 60 -ForegroundColor Cyan + +# 启动 tunnel +cloudflared tunnel --url http://localhost:8000