Initial commit: 浼佷笟寰俊 AI 鏈哄櫒浜哄姪鐞?MVP
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
33
.env.example
Normal file
33
.env.example
Normal file
@@ -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
|
||||
39
.env.prod.example
Normal file
39
.env.prod.example
Normal file
@@ -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
|
||||
29
.github-config.example
Normal file
29
.github-config.example
Normal file
@@ -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
|
||||
93
.github/workflows/build-deploy.yml
vendored
Normal file
93
.github/workflows/build-deploy.yml
vendored
Normal file
@@ -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 }}
|
||||
178
.github/workflows/deploy.yml
vendored
Normal file
178
.github/workflows/deploy.yml
vendored
Normal file
@@ -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
|
||||
57
.gitignore
vendored
Normal file
57
.gitignore
vendored
Normal file
@@ -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/
|
||||
139
README.md
Normal file
139
README.md
Normal file
@@ -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`
|
||||
2
admin/.env.local.example
Normal file
2
admin/.env.local.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# 复制为 .env.local 后前端会读取;直连后端时填下面一行
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:8000
|
||||
5
admin/.npmrc
Normal file
5
admin/.npmrc
Normal file
@@ -0,0 +1,5 @@
|
||||
legacy-peer-deps=true
|
||||
optional=true
|
||||
prefer-offline=false
|
||||
fetch-retries=5
|
||||
fetch-timeout=60000
|
||||
18
admin/Dockerfile
Normal file
18
admin/Dockerfile
Normal file
@@ -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"]
|
||||
39
admin/app/(main)/dashboard/page.tsx
Normal file
39
admin/app/(main)/dashboard/page.tsx
Normal file
@@ -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<any>(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 (
|
||||
<div>
|
||||
<h1 style={{ marginBottom: 24 }}>总览</h1>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="会话总数" value={stats.sessions} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="工单总数" value={stats.tickets} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="当前用户" value={user?.username || "-"} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
admin/app/(main)/kb/page.tsx
Normal file
59
admin/app/(main)/kb/page.tsx
Normal file
@@ -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<Doc[]>([]);
|
||||
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<Doc> = [
|
||||
{ title: "文件名", dataIndex: "filename", ellipsis: true },
|
||||
{ title: "大小", dataIndex: "size", width: 100, render: (s) => `${(s / 1024).toFixed(2)} KB` },
|
||||
{ title: "上传时间", dataIndex: "uploaded_at", width: 180 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title="知识库文档" style={{ marginBottom: 16 }}>
|
||||
<Upload.Dragger name="file" multiple={false} beforeUpload={beforeUpload} showUploadList={false} disabled={loading}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ fontSize: 48, color: "#1890ff" }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">当前为占位,文件将落本地/对象存储</p>
|
||||
</Upload.Dragger>
|
||||
</Card>
|
||||
<Card title="文档列表">
|
||||
<Table rowKey="id" columns={columns} dataSource={docs} pagination={{ pageSize: 20 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
admin/app/(main)/layout.tsx
Normal file
50
admin/app/(main)/layout.tsx
Normal file
@@ -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 (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Header style={{ display: "flex", alignItems: "center", gap: 24 }}>
|
||||
<div style={{ color: "#fff", fontWeight: "bold" }}>企微AI助手</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
selectedKeys={[pathname || ""]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
items={[
|
||||
{ key: "/dashboard", label: <Link href="/dashboard">总览</Link> },
|
||||
{ key: "/sessions", label: <Link href="/sessions">会话列表</Link> },
|
||||
{ key: "/tickets", label: <Link href="/tickets">工单列表</Link> },
|
||||
{ key: "/kb", label: <Link href="/kb">知识库</Link> },
|
||||
{ key: "/settings", label: <Link href="/settings">设置</Link> },
|
||||
{ key: "/users", label: <Link href="/users">用户管理</Link> },
|
||||
]}
|
||||
/>
|
||||
<Button type="link" onClick={logout} style={{ color: "rgba(255,255,255,0.85)" }}>
|
||||
退出
|
||||
</Button>
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>{children}</Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
90
admin/app/(main)/sessions/[id]/page.tsx
Normal file
90
admin/app/(main)/sessions/[id]/page.tsx
Normal file
@@ -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<Msg[]>([]);
|
||||
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 (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||
<Card
|
||||
title={`会话 #${id}`}
|
||||
extra={
|
||||
<Button type="primary" loading={ticketLoading} onClick={onCreateTicket}>
|
||||
转人工/创建工单
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={messages}
|
||||
renderItem={(m) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta title={`${m.role} · ${m.created_at}`} description={m.content} />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Space.Compact style={{ width: "100%", marginTop: 16 }}>
|
||||
<Input
|
||||
placeholder="输入回复内容"
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
onPressEnter={onSendReply}
|
||||
/>
|
||||
<Button type="primary" loading={sending} onClick={onSendReply}>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
46
admin/app/(main)/sessions/page.tsx
Normal file
46
admin/app/(main)/sessions/page.tsx
Normal file
@@ -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<Row[]>([]);
|
||||
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<Row> = [
|
||||
{ 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) => <Link href={`/sessions/${r.id}`}>查看消息</Link>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="会话列表">
|
||||
<Table rowKey="id" columns={columns} dataSource={list} loading={loading} pagination={{ pageSize: 20 }} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
59
admin/app/(main)/settings/page.tsx
Normal file
59
admin/app/(main)/settings/page.tsx
Normal file
@@ -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 (
|
||||
<Card title="模型/策略设置(占位)">
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} style={{ maxWidth: 600 }}>
|
||||
<Form.Item name="model_name" label="模型名称">
|
||||
<Input placeholder="gpt-4" />
|
||||
</Form.Item>
|
||||
<Form.Item name="faq_priority" label="FAQ 优先" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="rag_enabled" label="启用 RAG" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
70
admin/app/(main)/tickets/page.tsx
Normal file
70
admin/app/(main)/tickets/page.tsx
Normal file
@@ -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<Row[]>([]);
|
||||
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<Row> = [
|
||||
{ title: "ID", dataIndex: "id", width: 80 },
|
||||
{
|
||||
title: "会话",
|
||||
dataIndex: "session_id",
|
||||
width: 100,
|
||||
render: (sid) => <Link href={`/sessions/${sid}`}>{sid}</Link>,
|
||||
},
|
||||
{ title: "原因", dataIndex: "reason", ellipsis: true },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 120,
|
||||
render: (s) => (
|
||||
<Tag color={s === "open" ? "orange" : s === "closed" ? "green" : "blue"}>{s}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: "创建时间", dataIndex: "created_at", width: 180 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 150,
|
||||
render: (_, r) => (
|
||||
<Button size="small" onClick={() => handleStatusChange(r.id, "closed")}>
|
||||
关闭
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="工单列表">
|
||||
<Table rowKey="id" columns={columns} dataSource={list} loading={loading} pagination={{ pageSize: 20 }} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
118
admin/app/(main)/users/page.tsx
Normal file
118
admin/app/(main)/users/page.tsx
Normal file
@@ -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<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(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<Row> = [
|
||||
{ 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) => (
|
||||
<Switch checked={active} onChange={(v) => handleUpdate(r.id, { is_active: v })} />
|
||||
),
|
||||
},
|
||||
{ title: "创建时间", dataIndex: "created_at", width: 180 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 120,
|
||||
render: (_, r) => (
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Card title="用户管理">仅管理员可访问此页面。</Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="用户管理" extra={<Button onClick={() => setModalOpen(true)}>新建用户</Button>}>
|
||||
<Table rowKey="id" columns={columns} dataSource={list} loading={loading} pagination={{ pageSize: 20 }} />
|
||||
</Card>
|
||||
<Modal title="新建用户" open={modalOpen} onCancel={() => { setModalOpen(false); form.resetFields(); }} footer={null}>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" initialValue="admin">
|
||||
<Select options={[{ value: "admin", label: "管理员" }, { value: "user", label: "用户" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
创建
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
admin/app/AntdProvider.tsx
Normal file
12
admin/app/AntdProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ConfigProvider, App } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
|
||||
export function AntdProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App>{children}</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
7
admin/app/globals.css
Normal file
7
admin/app/globals.css
Normal file
@@ -0,0 +1,7 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
17
admin/app/layout.tsx
Normal file
17
admin/app/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "企微AI助手管理后台",
|
||||
description: "企业微信 AI 机器人助理",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
65
admin/app/login/page.tsx
Normal file
65
admin/app/login/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { login } from "@/lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("token", data.access_token);
|
||||
}
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 48, maxWidth: 400, margin: "0 auto" }}>
|
||||
<h1>登录</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>用户名</label>
|
||||
<br />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
style={{ width: "100%", padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label>密码</label>
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{ width: "100%", padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: "red", marginBottom: 16 }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={{ padding: "8px 16px" }}>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
admin/app/page.tsx
Normal file
16
admin/app/page.tsx
Normal file
@@ -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;
|
||||
}
|
||||
157
admin/lib/api.ts
Normal file
157
admin/lib/api.ts
Normal file
@@ -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<T = unknown> = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T | null;
|
||||
trace_id?: string;
|
||||
};
|
||||
|
||||
async function adminApi<T = unknown>(
|
||||
path: string,
|
||||
options: Omit<RequestInit, "body"> & { body?: object } = {}
|
||||
): Promise<ApiRes<T>> {
|
||||
try {
|
||||
const { body, ...rest } = options;
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...(rest.headers as Record<string, string>),
|
||||
};
|
||||
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<T> = 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<ApiRes<Array<{ id: string; external_user_id: string; external_name?: string; status: string; created_at: string }>>> {
|
||||
return adminApi("/api/admin/sessions");
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<ApiRes<{ id: string; external_user_id: string; external_name?: string; status: string; messages: Array<{ id: number; role: string; content: string; created_at: string }> }>> {
|
||||
return adminApi(`/api/admin/sessions/${id}`);
|
||||
}
|
||||
|
||||
// ============ Admin: Tickets ============
|
||||
export async function listTickets(): Promise<ApiRes<Array<{ id: string; session_id: string; reason: string; status: string; created_at: string }>>> {
|
||||
return adminApi("/api/admin/tickets");
|
||||
}
|
||||
|
||||
export async function createTicket(sessionId: string, reason?: string): Promise<ApiRes<{ id: string; session_id: string; reason: string; status: string }>> {
|
||||
return adminApi("/api/admin/tickets", { method: "POST", body: { session_id: sessionId, reason: reason || "" } });
|
||||
}
|
||||
|
||||
export async function updateTicket(id: string, status?: string, reason?: string): Promise<ApiRes<{ id: string; status: string; reason?: string }>> {
|
||||
return adminApi(`/api/admin/tickets/${id}`, { method: "PATCH", body: { status, reason } });
|
||||
}
|
||||
|
||||
// ============ Admin: KB ============
|
||||
export async function listKbDocs(): Promise<ApiRes<Array<{ id: string; filename: string; size: number; uploaded_at: string }>>> {
|
||||
return adminApi("/api/admin/kb/docs");
|
||||
}
|
||||
|
||||
export async function uploadKnowledge(file: File): Promise<ApiRes<{ id: string; filename: string; size: number }>> {
|
||||
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<ApiRes<{ model_name: string; strategy: Record<string, unknown> }>> {
|
||||
return adminApi("/api/admin/settings");
|
||||
}
|
||||
|
||||
export async function updateSettings(modelName?: string, strategy?: Record<string, unknown>): Promise<ApiRes<{ model_name: string; strategy: Record<string, unknown> }>> {
|
||||
return adminApi("/api/admin/settings", { method: "PATCH", body: { model_name: modelName, strategy } });
|
||||
}
|
||||
|
||||
// ============ Admin: Users ============
|
||||
export async function listUsers(): Promise<ApiRes<Array<{ id: string; username: string; role: string; is_active: boolean; created_at: string }>>> {
|
||||
return adminApi("/api/admin/users");
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password: string, role?: string, isActive?: boolean): Promise<ApiRes<{ id: string; username: string; role: string; is_active: boolean }>> {
|
||||
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<ApiRes<{ id: string; role?: string; is_active?: boolean }>> {
|
||||
return adminApi(`/api/admin/users/${id}`, { method: "PATCH", body: { password, role, is_active: isActive } });
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<ApiRes<null>> {
|
||||
return adminApi(`/api/admin/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
2
admin/next-env.d.ts
vendored
Normal file
2
admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
7
admin/next.config.ts
Normal file
7
admin/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
24
admin/package.json
Normal file
24
admin/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
0
admin/public/.gitkeep
Normal file
0
admin/public/.gitkeep
Normal file
21
admin/tsconfig.json
Normal file
21
admin/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
11
backend/Dockerfile
Normal file
11
backend/Dockerfile
Normal file
@@ -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"]
|
||||
40
backend/alembic.ini
Normal file
40
backend/alembic.ini
Normal file
@@ -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
|
||||
45
backend/alembic/env.py
Normal file
45
backend/alembic/env.py
Normal file
@@ -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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -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"}
|
||||
52
backend/alembic/versions/001_users_and_audit_logs.py
Normal file
52
backend/alembic/versions/001_users_and_audit_logs.py
Normal file
@@ -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")
|
||||
26
backend/alembic/versions/002_stamp.py
Normal file
26
backend/alembic/versions/002_stamp.py
Normal file
@@ -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
|
||||
36
backend/alembic/versions/003_add_missing_columns.py
Normal file
36
backend/alembic/versions/003_add_missing_columns.py
Normal file
@@ -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
|
||||
59
backend/alembic/versions/004_chat_sessions_and_messages.py
Normal file
59
backend/alembic/versions/004_chat_sessions_and_messages.py
Normal file
@@ -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")
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# backend app
|
||||
28
backend/app/config.py
Normal file
28
backend/app/config.py
Normal file
@@ -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()
|
||||
27
backend/app/database.py
Normal file
27
backend/app/database.py
Normal file
@@ -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()
|
||||
30
backend/app/deps.py
Normal file
30
backend/app/deps.py
Normal file
@@ -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
|
||||
49
backend/app/logging_config.py
Normal file
49
backend/app/logging_config.py
Normal file
@@ -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))
|
||||
68
backend/app/main.py
Normal file
68
backend/app/main.py
Normal file
@@ -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"}
|
||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal file
@@ -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"]
|
||||
23
backend/app/models/audit_log.py
Normal file
23
backend/app/models/audit_log.py
Normal file
@@ -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)
|
||||
5
backend/app/models/base.py
Normal file
5
backend/app/models/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
18
backend/app/models/message.py
Normal file
18
backend/app/models/message.py
Normal file
@@ -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")
|
||||
19
backend/app/models/session.py
Normal file
19
backend/app/models/session.py
Normal file
@@ -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")
|
||||
17
backend/app/models/ticket.py
Normal file
17
backend/app/models/ticket.py
Normal file
@@ -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)
|
||||
22
backend/app/models/user.py
Normal file
22
backend/app/models/user.py
Normal file
@@ -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)
|
||||
2
backend/app/routers/__init__.py
Normal file
2
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# routers
|
||||
from app.routers import auth, wecom
|
||||
1
backend/app/routers/admin/__init__.py
Normal file
1
backend/app/routers/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# admin routers
|
||||
31
backend/app/routers/admin/kb.py
Normal file
31
backend/app/routers/admin/kb.py
Normal file
@@ -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(),
|
||||
}
|
||||
40
backend/app/routers/admin/sessions.py
Normal file
40
backend/app/routers/admin/sessions.py
Normal file
@@ -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(),
|
||||
}
|
||||
37
backend/app/routers/admin/settings.py
Normal file
37
backend/app/routers/admin/settings.py
Normal file
@@ -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(),
|
||||
}
|
||||
52
backend/app/routers/admin/tickets.py
Normal file
52
backend/app/routers/admin/tickets.py
Normal file
@@ -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(),
|
||||
}
|
||||
74
backend/app/routers/admin/users.py
Normal file
74
backend/app/routers/admin/users.py
Normal file
@@ -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(),
|
||||
}
|
||||
47
backend/app/routers/auth.py
Normal file
47
backend/app/routers/auth.py
Normal file
@@ -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,
|
||||
}
|
||||
15
backend/app/routers/health.py
Normal file
15
backend/app/routers/health.py
Normal file
@@ -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(),
|
||||
}
|
||||
12
backend/app/routers/knowledge.py
Normal file
12
backend/app/routers/knowledge.py
Normal file
@@ -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()}
|
||||
53
backend/app/routers/sessions.py
Normal file
53
backend/app/routers/sessions.py
Normal file
@@ -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()}
|
||||
15
backend/app/routers/settings.py
Normal file
15
backend/app/routers/settings.py
Normal file
@@ -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()}
|
||||
67
backend/app/routers/tickets.py
Normal file
67
backend/app/routers/tickets.py
Normal file
@@ -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()}
|
||||
163
backend/app/routers/wecom.py
Normal file
163
backend/app/routers/wecom.py
Normal file
@@ -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")
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# services
|
||||
39
backend/app/services/auth_service.py
Normal file
39
backend/app/services/auth_service.py
Normal file
@@ -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()
|
||||
30
backend/app/services/session_service.py
Normal file
30
backend/app/services/session_service.py
Normal file
@@ -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
|
||||
54
backend/app/services/wecom_api.py
Normal file
54
backend/app/services/wecom_api.py
Normal file
@@ -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"))
|
||||
119
backend/app/services/wecom_crypto.py
Normal file
119
backend/app/services/wecom_crypto.py
Normal file
@@ -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"""<xml>
|
||||
<ToUserName><![CDATA[{to_user}]]></ToUserName>
|
||||
<FromUserName><![CDATA[{from_user}]]></FromUserName>
|
||||
<CreateTime>{int(__import__("time").time())}</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[{content}]]></Content>
|
||||
</xml>"""
|
||||
|
||||
|
||||
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"""<xml>
|
||||
<Encrypt><![CDATA[{encrypt}]]></Encrypt>
|
||||
<MsgSignature><![CDATA[{signature}]]></MsgSignature>
|
||||
<TimeStamp>{timestamp}</TimeStamp>
|
||||
<Nonce><![CDATA[{nonce}]]></Nonce>
|
||||
</xml>"""
|
||||
26
backend/config.py
Normal file
26
backend/config.py
Normal file
@@ -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()
|
||||
4
backend/pyproject.toml
Normal file
4
backend/pyproject.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
22
backend/requirements.txt
Normal file
22
backend/requirements.txt
Normal file
@@ -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
|
||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# tests
|
||||
26
backend/tests/test_auth.py
Normal file
26
backend/tests/test_auth.py
Normal file
@@ -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
|
||||
16
backend/tests/test_health.py
Normal file
16
backend/tests/test_health.py
Normal file
@@ -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
|
||||
26
backend/tests/test_wecom.py
Normal file
26
backend/tests/test_wecom.py
Normal file
@@ -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
|
||||
95
deploy/ci/github-actions.yml
Normal file
95
deploy/ci/github-actions.yml
Normal file
@@ -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
|
||||
52
deploy/docker/admin.Dockerfile
Normal file
52
deploy/docker/admin.Dockerfile
Normal file
@@ -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"]
|
||||
46
deploy/docker/backend.Dockerfile
Normal file
46
deploy/docker/backend.Dockerfile
Normal file
@@ -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"]
|
||||
138
deploy/docker/nginx.conf
Normal file
138
deploy/docker/nginx.conf
Normal file
@@ -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 '<!DOCTYPE html><html><head><title>Admin 服务维护中</title></head><body><h1>Admin 服务维护中</h1><p>管理后台暂时不可用,请稍后再试。</p></body></html>';
|
||||
}
|
||||
}
|
||||
}
|
||||
59
deploy/nginx-ssl.conf
Normal file
59
deploy/nginx-ssl.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
deploy/nginx.conf
Normal file
36
deploy/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
deploy/scripts/acceptance.sh
Normal file
32
deploy/scripts/acceptance.sh
Normal file
@@ -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 ==="
|
||||
88
deploy/scripts/deploy-minimal.sh
Normal file
88
deploy/scripts/deploy-minimal.sh
Normal file
@@ -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 ""
|
||||
52
deploy/scripts/fix_alembic_version.py
Normal file
52
deploy/scripts/fix_alembic_version.py
Normal file
@@ -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()
|
||||
58
deploy/scripts/fix_users_table.py
Normal file
58
deploy/scripts/fix_users_table.py
Normal file
@@ -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()
|
||||
31
deploy/scripts/migrate.ps1
Normal file
31
deploy/scripts/migrate.ps1
Normal file
@@ -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
|
||||
}
|
||||
72
deploy/scripts/migrate.py
Normal file
72
deploy/scripts/migrate.py
Normal file
@@ -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()
|
||||
27
deploy/scripts/migrate.sh
Normal file
27
deploy/scripts/migrate.sh
Normal file
@@ -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 "迁移完成。"
|
||||
69
deploy/scripts/seed.py
Normal file
69
deploy/scripts/seed.py
Normal file
@@ -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()
|
||||
67
deploy/scripts/setup-ssl.sh
Normal file
67
deploy/scripts/setup-ssl.sh
Normal file
@@ -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"
|
||||
55
deploy/scripts/start.sh
Normal file
55
deploy/scripts/start.sh
Normal file
@@ -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"
|
||||
18
deploy/scripts/stop.sh
Normal file
18
deploy/scripts/stop.sh
Normal file
@@ -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 "=== 停止完成 ==="
|
||||
64
deploy/scripts/update.sh
Normal file
64
deploy/scripts/update.sh
Normal file
@@ -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"
|
||||
60
docker-compose.minimal.yml
Normal file
60
docker-compose.minimal.yml
Normal file
@@ -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:
|
||||
94
docker-compose.prod.yml
Normal file
94
docker-compose.prod.yml
Normal file
@@ -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:
|
||||
53
docker-compose.yml
Normal file
53
docker-compose.yml
Normal file
@@ -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:
|
||||
151
docs/cloudflared-quickstart.md
Normal file
151
docs/cloudflared-quickstart.md
Normal file
@@ -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/
|
||||
312
docs/cloudflared-setup.md
Normal file
312
docs/cloudflared-setup.md
Normal file
@@ -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\<tunnel-id>.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\<tunnel-id>.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
|
||||
```
|
||||
415
docs/deploy-cloud-minimal.md
Normal file
415
docs/deploy-cloud-minimal.md
Normal file
@@ -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 '<xml><Encrypt><![CDATA[加密内容]]></Encrypt></xml>'
|
||||
```
|
||||
|
||||
### 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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user