Initial commit: 浼佷笟寰俊 AI 鏈哄櫒浜哄姪鐞?MVP
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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"
|
||||
Reference in New Issue
Block a user