Initial commit: 浼佷笟寰俊 AI 鏈哄櫒浜哄姪鐞?MVP

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bujie9527
2026-02-05 16:36:32 +08:00
commit 59275ed4dc
126 changed files with 9120 additions and 0 deletions

33
.env.example Normal file
View 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
View 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
# JWTadmin 登录,可选)
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
View 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
View 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
View 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
View 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
View 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. **访问地址**
- 管理后台(经 nginxhttp://localhost
- 管理后台直连http://localhost:3000
- 后端 API经 nginxhttp://localhost/api/health
- 后端 API直连http://localhost:8000/api/health
- PostgreSQLlocalhost: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
View File

@@ -0,0 +1,2 @@
# 复制为 .env.local 后前端会读取;直连后端时填下面一行
NEXT_PUBLIC_API_BASE=http://localhost:8000

5
admin/.npmrc Normal file
View 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
View 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"]

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

7
admin/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

24
admin/package.json Normal file
View 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
View File

21
admin/tsconfig.json Normal file
View 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
View 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
View 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
View 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()

View 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"}

View 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")

View 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

View 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

View 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
View File

@@ -0,0 +1 @@
# backend app

28
backend/app/config.py Normal file
View 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
View 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
View 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

View 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
View 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"}

View 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"]

View 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)

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View 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")

View 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")

View 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)

View 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)

View File

@@ -0,0 +1,2 @@
# routers
from app.routers import auth, wecom

View File

@@ -0,0 +1 @@
# admin routers

View 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(),
}

View 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(),
}

View 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(),
}

View 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(),
}

View 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(),
}

View File

@@ -0,0 +1,47 @@
"""Auth APIPOST /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,
}

View 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(),
}

View 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()}

View 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()}

View 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()}

View 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()}

View 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")

View File

@@ -0,0 +1 @@
# services

View File

@@ -0,0 +1,39 @@
"""密码 bcrypt hashJWT 创建与解码,带过期时间。"""
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()

View 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

View 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"))

View 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 解析为 dictToUserName, 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
View 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
View File

@@ -0,0 +1,4 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]

22
backend/requirements.txt Normal file
View 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

View File

@@ -0,0 +1 @@
# tests

View 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

View 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

View 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

View 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

View 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"]

View 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
View 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
View 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
View 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;
}
}
}

View 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&timestamp=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 ==="

View 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 ""

View 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()

View 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()

View 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
View 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
View 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
View 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()

View 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
View 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
View 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
View 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"

View 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
View 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
View 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:

View 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
View 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` 命令
- **方式 CMSI 安装)**:双击 `cloudflared-windows-amd64.msi` 安装,会自动添加到系统 PATH
**最新版本下载链接**(直接下载):
- Windows 64位 EXEhttps://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe
- Windows 64位 MSIhttps://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. 在企业微信中验证收到回复
---
## 七、常见问题
### 问题 1cloudflared 连接失败
**解决方案**
- 检查本地服务是否运行:`docker compose ps`
- 检查端口是否正确:`netstat -an | findstr 8000`
- 检查防火墙是否阻止了 cloudflared
### 问题 2企微回调失败
**解决方案**
- 确保 cloudflared URL 可访问:在浏览器中打开 `https://你的域名.trycloudflare.com/api/health`
- 检查 Token 和 EncodingAESKey 是否一致
- 查看后端日志:`docker compose logs backend | grep wecom`
### 问题 3cloudflared 进程意外退出
**解决方案**
- 使用后台运行方式(见上方)
- 或使用 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
```

View File

@@ -0,0 +1,415 @@
# 云端最小回调壳部署方案
## 一、目标
**阶段目标**:在备案域名上部署最小可用回调壳,使企业微信能完成 URL 校验与回调联调。
**最小功能范围**
-`/api/wecom/callback` GET 校验(兼容 `signature`/`msg_signature`
-`/api/wecom/callback` POST 密文消息回调验签、解密、echo 回复)
- ✅ 结构化日志 + trace_id
- ✅ Nginx 反代 + HTTPSLet's Encrypt
- ⏸️ 数据库(可先不启用,但接口与配置要预留)
- ⏸️ Admin 后台(可先占位)
---
## 二、架构
```
企业微信 → HTTPS (443) → Nginx → Backend (8000)
PostgreSQL (可选)
```
**服务清单**
- `backend`: Python 3.12 + FastAPI + Uvicorn最小回调壳
- `nginx`: 反代 + HTTPSLet'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
# JWTadmin 登录,可选)
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
# SSLLet'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. **服务器**LinuxUbuntu 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使用 CertbotLet'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&timestamp=123&nonce=abc&echostr=xxx"
```
### 5.2 本地测试 POST 回调
```bash
# 使用企业微信官方测试工具生成测试请求
# 或使用 curl 模拟(需要正确的签名和加密)
curl -X POST "http://localhost:8000/api/wecom/callback?msg_signature=xxx&timestamp=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