Compare commits
40 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
d2ae4b63c6 | |
|
|
f19b70f1d0 | |
|
|
f2c96f335f | |
|
|
f44453d93e | |
|
|
d69b346f4c | |
|
|
dbf6f72dac | |
|
|
c76d05edb4 | |
|
|
9079d82729 | |
|
|
60b4c1f3a6 | |
|
|
e71bd889b1 | |
|
|
2c505514a5 | |
|
|
d7507e811b | |
|
|
27fa9317c5 | |
|
|
861d7e3463 | |
|
|
3fe28934cc | |
|
|
41f71e649d | |
|
|
ad16567e82 | |
|
|
aa99ee1f6a | |
|
|
31708df6cb | |
|
|
f3d9429b28 | |
|
|
af735bd93d | |
|
|
3c2ac639b4 | |
|
|
2591996a48 | |
|
|
abc5342258 | |
|
|
ac9c2f5fd4 | |
|
|
cec4f98d42 | |
|
|
df61cd870d | |
|
|
cc1817078a | |
|
|
a3ae293d42 | |
|
|
181f4565b4 | |
|
|
a99cc389c8 | |
|
|
a69dd645ca | |
|
|
423e768c3c | |
|
|
346b5ffb06 | |
|
|
7c63ec1ebe | |
|
|
6e347be83b | |
|
|
c6188809ff | |
|
|
498bd97f99 | |
|
|
4715cd4a86 | |
|
|
bbcc5466f0 |
36
.env.example
36
.env.example
|
|
@ -1,15 +1,28 @@
|
|||
# ==================== 部署模式说明 ====================
|
||||
# 1. 默认 Docker 一体化部署(./start.sh / docker-compose.yml):
|
||||
# 只使用当前文件(根目录 .env)和 Docker Compose 注入的环境变量,不读取 backend/.env。
|
||||
# 2. 直接运行后端或外接中间件部署:
|
||||
# `start-external.sh` 也只读取当前文件;
|
||||
# 外接中间件时可直接在这里填写 MYSQL_* / REDIS_*,脚本会转换给后端使用。
|
||||
|
||||
# ==================== 数据库配置 ====================
|
||||
# MySQL数据库配置
|
||||
MYSQL_ROOT_PASSWORD=Unis@123
|
||||
# MySQL 初始化参数(Docker 内置 MySQL)。
|
||||
# 当后端也运行在 Docker 中时,数据库主机应为服务名 `mysql`。
|
||||
# 使用 `./start-external.sh` 时,请改成外部 MySQL 地址或服务名。
|
||||
MYSQL_HOST=mysql
|
||||
MYSQL_ROOT_PASSWORD=change_this_password
|
||||
MYSQL_DATABASE=imeeting
|
||||
MYSQL_USER=imeeting
|
||||
MYSQL_PASSWORD=Unis@123
|
||||
MYSQL_PASSWORD=change_this_password
|
||||
MYSQL_PORT=3306
|
||||
|
||||
# ==================== 缓存配置 ====================
|
||||
# Redis配置
|
||||
# Redis 初始化参数(Docker 内置 Redis)。
|
||||
# 当后端也运行在 Docker 中时,Redis 主机应为服务名 `redis`。
|
||||
# 使用 `./start-external.sh` 时,请改成外部 Redis 地址或服务名。
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Unis@123
|
||||
REDIS_PASSWORD=change_this_password
|
||||
REDIS_DB=0
|
||||
|
||||
# ==================== 应用端口配置 ====================
|
||||
|
|
@ -17,14 +30,11 @@ REDIS_DB=0
|
|||
HTTP_PORT=80
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
# 应用访问地址(用于生成外部链接、二维码等)
|
||||
# - 直接访问: http://服务器IP
|
||||
# - 域名访问: https://your-domain.com (需配置接入服务器Nginx)
|
||||
BASE_URL=https://imeeting.unisspace.com
|
||||
# 应用访问地址(用于生成外部链接、客户端下载链接,以及音频转录时提供给云端拉取音频文件的公网 URL)
|
||||
# - 本地联调可先填写: http://localhost
|
||||
# - 使用云端音频转录时,必须改成外部可访问的域名或公网地址,不能填写容器名、127.0.0.1 或内网地址
|
||||
# - 不要以 / 结尾,例如: https://your-domain.com
|
||||
BASE_URL=https://your-domain.com
|
||||
|
||||
# 前端API地址(通过Nginx代理访问后端)
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# ==================== LLM配置 ====================
|
||||
# 通义千问API密钥(请替换为实际密钥)
|
||||
QWEN_API_KEY=sk-c2bf06ea56b4491ea3d1e37fdb472b8f
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
|
|
@ -2,26 +2,40 @@
|
|||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors and local tooling
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.claude/
|
||||
.gemini-clipboard/
|
||||
.memsearch/
|
||||
frontend/.memsearch/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
backend/.env
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/.env.development.local
|
||||
frontend/.env.test.local
|
||||
frontend/.env.production.local
|
||||
|
||||
# Docker / Data
|
||||
# Project data and local-only assets
|
||||
data/
|
||||
backups/
|
||||
logs_export/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
frontend/dist/
|
||||
frontend/logs/
|
||||
backend/uploads/
|
||||
backend/logs/
|
||||
backend/venv/
|
||||
backend/test/
|
||||
# Only keep the latest full-deploy SQL entrypoints in repo
|
||||
backend/sql/*
|
||||
!backend/sql/imeeting-schema-latest.sql
|
||||
!backend/sql/imeeting-seed-latest.sql
|
||||
backend/sql/migrations/
|
||||
backend/scripts/
|
||||
资料/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
|
@ -29,13 +43,9 @@ __pycache__/
|
|||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.env/
|
||||
.venv/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
|
@ -43,21 +53,104 @@ lib/
|
|||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
share/python-wheels/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
backend/venv/
|
||||
backend/__pycache__/
|
||||
backend/logs/
|
||||
MANIFEST
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pyre/
|
||||
.pytype/
|
||||
cython_debug/
|
||||
.pdm.toml
|
||||
__pypackages__/
|
||||
.ipynb_checkpoints
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
instance/
|
||||
.webassets-cache
|
||||
.scrapy
|
||||
docs/_build/
|
||||
.pybuilder/
|
||||
target/
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
*.manifest
|
||||
*.spec
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Project specific
|
||||
backend/uploads/
|
||||
资料/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
# Node.js / frontend
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/logs/
|
||||
jspm_packages/
|
||||
web_modules/
|
||||
bower_components/
|
||||
.npm
|
||||
.parcel-cache/
|
||||
.cache/
|
||||
.next/
|
||||
out/
|
||||
.nuxt/
|
||||
.vuepress/dist
|
||||
.temp
|
||||
.docusaurus
|
||||
.serverless/
|
||||
.fusebox/
|
||||
.dynamodb/
|
||||
.tern-port
|
||||
.vscode-test
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
.nyc_output
|
||||
coverage/
|
||||
*.lcov
|
||||
*.tsbuildinfo
|
||||
*.pid
|
||||
*.pid.lock
|
||||
*.seed
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
.node_repl_history
|
||||
*.tgz
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.yarn-integrity
|
||||
.pnp.*
|
||||
pids/
|
||||
logs/
|
||||
|
|
|
|||
120
DOCKER_README.md
120
DOCKER_README.md
|
|
@ -12,22 +12,19 @@
|
|||
| `start.sh` ⭐ | 一键启动脚本(推荐) |
|
||||
| `stop.sh` | 停止服务脚本 |
|
||||
| `manage.sh` | 服务管理脚本 |
|
||||
| `generate-ssl-cert.sh` | SSL证书生成脚本 |
|
||||
| `DOCKER_DEPLOYMENT.md` | 详细部署文档 |
|
||||
| `DOCKER_README.md` | 本文档 |
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
```
|
||||
方式一:直接访问
|
||||
用户 → http://服务器IP → iMeeting Nginx (80) → Frontend/Backend
|
||||
用户 → http://服务器IP → iMeeting Frontend(Web) (80) → Frontend/Backend
|
||||
|
||||
方式二:域名访问(HTTPS)
|
||||
用户 → https://domain → 接入服务器Nginx (SSL) → iMeeting服务器 (80) → Frontend/Backend
|
||||
```
|
||||
|
||||
**5个服务容器**:
|
||||
- **Nginx**: HTTP入口(仅HTTP,SSL由接入服务器处理)
|
||||
**4个服务容器**:
|
||||
- **Frontend**: React前端应用
|
||||
- **Backend**: FastAPI后端服务
|
||||
- **MySQL**: 数据库
|
||||
|
|
@ -43,43 +40,35 @@
|
|||
```bash
|
||||
# 1. 复制并配置环境变量
|
||||
cp .env.example .env
|
||||
vim .env # 配置七牛云、LLM密钥等
|
||||
vim .env # 配置 BASE_URL、密码等
|
||||
|
||||
# 2. 一键启动
|
||||
./start.sh
|
||||
```
|
||||
|
||||
说明:
|
||||
- 完整 Docker 一体化部署不会读取 `backend/.env`
|
||||
|
||||
脚本会自动完成:
|
||||
- ✅ 检查Docker依赖
|
||||
- ✅ 创建必要目录
|
||||
- ✅ 生成SSL证书
|
||||
- ✅ 配置后端环境变量
|
||||
- ✅ 构建前端
|
||||
- ✅ 启动所有服务
|
||||
- ✅ 等待健康检查
|
||||
|
||||
说明:
|
||||
- 后端镜像现在依赖系统级 `ffmpeg/ffprobe` 做音频预处理,已在 `backend/Dockerfile` 中安装,无需宿主机额外安装。
|
||||
|
||||
### 方式二:手动启动
|
||||
|
||||
```bash
|
||||
# 1. 配置环境变量
|
||||
cp .env.example .env
|
||||
cp backend/.env.example backend/.env
|
||||
vim .env # 配置主环境变量
|
||||
vim backend/.env # 配置后端环境变量
|
||||
|
||||
# 2. 生成SSL证书
|
||||
./generate-ssl-cert.sh
|
||||
|
||||
# 3. 构建前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 4. 启动所有服务
|
||||
# 2. 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 5. 查看服务状态
|
||||
# 3. 查看服务状态
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
|
|
@ -94,24 +83,19 @@ docker-compose ps
|
|||
|
||||
**域名访问(HTTPS)**:
|
||||
- 需要在接入服务器配置Nginx反向代理
|
||||
- 参考文档:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
|
||||
- 参考本文“域名和 HTTPS 配置”章节
|
||||
- 访问示例:https://imeeting.yourdomain.com
|
||||
|
||||
## ⚙️ 环境变量配置
|
||||
|
||||
### 必须配置项
|
||||
|
||||
编辑 `.env` 文件,修改以下配置:
|
||||
编辑根目录 `.env` 文件,修改以下配置:
|
||||
|
||||
```bash
|
||||
# 七牛云存储(必填,否则无法上传文件)
|
||||
QINIU_ACCESS_KEY=your_actual_access_key
|
||||
QINIU_SECRET_KEY=your_actual_secret_key
|
||||
QINIU_BUCKET=your_bucket_name
|
||||
QINIU_DOMAIN=your_domain.clouddn.com
|
||||
|
||||
# LLM API(必填,否则无法使用AI功能)
|
||||
QWEN_API_KEY=your_actual_qwen_api_key
|
||||
# Docker 一体化部署下,后端容器访问内置 MySQL/Redis 使用服务名
|
||||
MYSQL_HOST=mysql
|
||||
REDIS_HOST=redis
|
||||
|
||||
# 生产环境必改密码
|
||||
MYSQL_ROOT_PASSWORD=change_this_password
|
||||
|
|
@ -122,18 +106,42 @@ REDIS_PASSWORD=change_this_password
|
|||
### 可选配置项
|
||||
|
||||
```bash
|
||||
# 应用访问地址(用于生成二维码等)
|
||||
# 应用访问地址(用于生成外链,以及音频转录时给云端拉取音频文件)
|
||||
# 如果使用云端音频转录,必须改成外部可访问的域名或公网地址,不能填写 localhost / 127.0.0.1 / 容器名
|
||||
BASE_URL=http://localhost # 生产环境改为: https://your-domain.com
|
||||
|
||||
# Nginx端口(默认80/443)
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
|
||||
# 如需直接访问后端/前端(开发调试用)
|
||||
# BACKEND_PORT=8001
|
||||
# FRONTEND_PORT=3001
|
||||
# 如需调整容器对外端口
|
||||
# BACKEND_PORT=8000
|
||||
# HTTP_PORT=80
|
||||
```
|
||||
|
||||
### 外接 MySQL / Redis 部署
|
||||
|
||||
如果使用 `./start-external.sh`,直接修改根目录 `.env` 即可,不需要 `backend/.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
```
|
||||
|
||||
根目录 `.env` 里需要重点配置:
|
||||
|
||||
- `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`
|
||||
- `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` / `REDIS_PASSWORD`
|
||||
- `BASE_URL`
|
||||
|
||||
说明:
|
||||
- `start-external.sh` 会优先读取 `.env` 中的 `DB_*`;如果未设置,则自动回退到 `MYSQL_*`
|
||||
|
||||
### 音频预处理依赖
|
||||
|
||||
- Docker 部署:后端容器内已安装 `ffmpeg`
|
||||
- 非 Docker 部署:请确保服务器可执行 `ffmpeg` 和 `ffprobe`
|
||||
|
||||
## 📦 数据目录
|
||||
|
||||
所有数据存储在 `./data/` 目录:
|
||||
|
|
@ -146,7 +154,7 @@ data/
|
|||
└── logs/ # 日志文件
|
||||
├── backend/
|
||||
├── frontend/
|
||||
└── nginx/
|
||||
└── frontend/
|
||||
```
|
||||
|
||||
**重要提示**:
|
||||
|
|
@ -182,13 +190,13 @@ docker-compose ps
|
|||
|
||||
# 查看日志
|
||||
docker-compose logs -f # 所有服务
|
||||
docker-compose logs -f nginx # Nginx日志
|
||||
docker-compose logs -f frontend # 前端Web日志
|
||||
docker-compose logs -f backend # 后端日志
|
||||
tail -f data/logs/nginx/imeeting_access.log # 访问日志
|
||||
tail -f data/logs/frontend/*.log # 前端容器日志目录
|
||||
|
||||
# 重启服务
|
||||
docker-compose restart # 重启所有
|
||||
docker-compose restart nginx # 重启Nginx
|
||||
docker-compose restart frontend # 重启前端
|
||||
|
||||
# 停止服务
|
||||
./stop.sh # 交互式停止
|
||||
|
|
@ -201,7 +209,7 @@ iMeeting服务器本身仅提供HTTP服务。如需通过域名访问(HTTPS)
|
|||
|
||||
### 配置接入服务器
|
||||
|
||||
完整配置步骤请参考:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
|
||||
完整配置步骤请参考本文档中的反向代理章节
|
||||
|
||||
**简要步骤**:
|
||||
1. 在接入服务器安装Nginx
|
||||
|
|
@ -225,7 +233,7 @@ server {
|
|||
}
|
||||
```
|
||||
|
||||
详见:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
|
||||
详见:本文档中的反向代理章节
|
||||
|
||||
## 🔍 故障排查
|
||||
|
||||
|
|
@ -236,7 +244,7 @@ server {
|
|||
docker-compose logs -f
|
||||
|
||||
# 文件日志
|
||||
tail -f data/logs/nginx/imeeting_error.log
|
||||
tail -f data/logs/frontend/*.log
|
||||
tail -f data/logs/backend/*.log
|
||||
```
|
||||
|
||||
|
|
@ -267,19 +275,19 @@ curl http://localhost/docs
|
|||
|------|----------|
|
||||
| 端口被占用 | 修改.env中的HTTP_PORT |
|
||||
| 502错误 | 检查backend和frontend是否健康 |
|
||||
| 数据库连接失败 | 检查backend/.env配置 |
|
||||
| 数据库连接失败 | 检查根目录 `.env` 中的数据库配置;外接中间件模式确认 `MYSQL_HOST` / `DB_HOST` 是否正确 |
|
||||
| 前端无法访问API | 检查VITE_API_BASE_URL配置 |
|
||||
| 如何配置HTTPS | 参考GATEWAY_NGINX_CONFIG.md配置接入服务器 |
|
||||
| 音频转录拉不到音频文件 | 检查 `BASE_URL` 是否为云端可访问的完整地址 |
|
||||
| 如何配置HTTPS | 参考本文档中的反向代理章节 |
|
||||
|
||||
详见:`DOCKER_DEPLOYMENT.md` 故障排查章节
|
||||
详见:`DOCKER_README.md` 故障排查章节
|
||||
|
||||
## 📊 服务组件
|
||||
|
||||
| 服务 | 容器名 | 内部端口 | 外部端口 | 健康检查 |
|
||||
|------|--------|----------|----------|----------|
|
||||
| Nginx | imeeting-nginx | 80 | 80 | ✅ |
|
||||
| Frontend | imeeting-frontend | 3001 | - | ✅ |
|
||||
| Backend | imeeting-backend | 8001 | - | ✅ |
|
||||
| Frontend(Web) | imeeting-frontend | 80 | 80 | ✅ |
|
||||
| Backend | imeeting-backend | 8000 | 8000 | ✅ |
|
||||
| MySQL | imeeting-mysql | 3306 | - | ✅ |
|
||||
| Redis | imeeting-redis | 6379 | - | ✅ |
|
||||
|
||||
|
|
@ -309,7 +317,7 @@ npm run build
|
|||
cd ..
|
||||
docker-compose build frontend
|
||||
docker-compose up -d frontend
|
||||
docker-compose restart nginx
|
||||
docker-compose restart frontend
|
||||
```
|
||||
|
||||
### 仅更新后端
|
||||
|
|
@ -338,7 +346,7 @@ docker-compose exec mysql mysqldump -uroot -p imeeting > backup.sql
|
|||
tar czf imeeting_backup_$(date +%Y%m%d).tar.gz data/
|
||||
|
||||
# 备份配置
|
||||
tar czf imeeting_config_$(date +%Y%m%d).tar.gz .env backend/.env nginx/
|
||||
tar czf imeeting_config_$(date +%Y%m%d).tar.gz .env frontend/nginx.conf
|
||||
```
|
||||
|
||||
### 恢复数据
|
||||
|
|
@ -366,8 +374,8 @@ tar xzf imeeting_backup_20240101.tar.gz
|
|||
|
||||
3. ✅ **设置文件权限**
|
||||
```bash
|
||||
chmod 600 .env backend/.env
|
||||
chmod 600 nginx/ssl/server.key
|
||||
chmod 600 .env
|
||||
确保接入层反向代理证书文件权限正确
|
||||
```
|
||||
|
||||
4. ✅ **启用防火墙**
|
||||
|
|
@ -389,8 +397,8 @@ tar xzf imeeting_backup_20240101.tar.gz
|
|||
|
||||
详细的部署说明、配置选项、性能优化等,请查看:
|
||||
|
||||
- **详细部署文档**: [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md)
|
||||
- **Nginx配置说明**: [nginx/README.md](nginx/README.md)
|
||||
- **部署说明**: [DOCKER_README.md](DOCKER_README.md)
|
||||
- **前端代理配置**: [frontend/nginx.conf](frontend/nginx.conf)
|
||||
|
||||
## 🆘 获取帮助
|
||||
|
||||
|
|
@ -398,7 +406,7 @@ tar xzf imeeting_backup_20240101.tar.gz
|
|||
|
||||
1. 查看日志:`docker-compose logs -f`
|
||||
2. 查看健康状态:`docker-compose ps`
|
||||
3. 查看详细文档:`DOCKER_DEPLOYMENT.md`
|
||||
3. 查看详细文档:`DOCKER_README.md`
|
||||
4. 提交Issue到项目仓库
|
||||
|
||||
## 📞 快速命令参考
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
# UI Modernization & Standardization Plan
|
||||
|
||||
## Stage 1: Foundation (Global Theme & Layout)
|
||||
**Goal**: Establish a consistent visual base and layout structure.
|
||||
**Success Criteria**:
|
||||
- Global `ConfigProvider` with a modern theme (v5 tokens).
|
||||
- A reusable `MainLayout` component replacing duplicated header/sidebar logic.
|
||||
- Unified navigation experience across Admin and User dashboards.
|
||||
**Status**: Complete
|
||||
|
||||
## Stage 2: Component Standardization
|
||||
**Goal**: Replace custom, inconsistent components with Ant Design standards.
|
||||
**Success Criteria**:
|
||||
- `ListTable` and `DataTable` replaced by `antd.Table`. (Complete)
|
||||
- `FormModal` and `ConfirmDialog` replaced by `antd.Modal`. (Complete)
|
||||
- `Toast` and custom notifications replaced by `antd.message` and `antd.notification`. (Complete)
|
||||
- Custom `Dropdown`, `Breadcrumb`, and `PageLoading` replaced by `antd` equivalents. (Complete)
|
||||
**Status**: Complete
|
||||
|
||||
## Stage 3: Visual Polish & UX
|
||||
**Goal**: Enhance design details and interactive experience.
|
||||
**Success Criteria**:
|
||||
- Modernized dashboard cards with subtle shadows and transitions. (Complete)
|
||||
- Standardized `Empty` states and `Skeleton` loaders. (Complete)
|
||||
- Responsive design improvements for various screen sizes. (Complete)
|
||||
- Clean up redundant CSS files and components. (In Progress)
|
||||
**Status**: In Progress
|
||||
17
README.md
17
README.md
|
|
@ -82,8 +82,8 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
|
|||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 16+
|
||||
- Python 3.9+
|
||||
- Node.js 22.12+
|
||||
- Python 3.12+
|
||||
- MySQL 5.7+
|
||||
- Redis 5.0+
|
||||
- Docker (可选)
|
||||
|
|
@ -95,7 +95,7 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
|
|||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
python app/main.py
|
||||
```
|
||||
|
||||
默认运行在 `http://localhost:8000`
|
||||
|
|
@ -110,6 +110,17 @@ npm run dev
|
|||
|
||||
默认运行在 `http://localhost:5173`
|
||||
|
||||
#### 使用 Conda 一键启动前后端
|
||||
|
||||
项目根目录提供了 Conda 启动脚本,会分别创建并使用独立环境启动前后端:
|
||||
|
||||
- 后端环境: `imetting_backend` (Python 3.12)
|
||||
- 前端环境: `imetting_frontend` (Node.js 22)
|
||||
|
||||
```bash
|
||||
./start-conda.sh
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
详细的配置文档请参考:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ ENV/
|
|||
|
||||
# 用户上传文件(最重要!)
|
||||
uploads/
|
||||
test/
|
||||
sql/
|
||||
scripts/
|
||||
|
||||
# 测试和开发文件
|
||||
test/
|
||||
|
|
@ -38,6 +41,8 @@ htmlcov/
|
|||
logs/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
|
|
|
|||
|
|
@ -1,31 +1,30 @@
|
|||
# ==================== 数据库配置 ====================
|
||||
# Docker环境使用容器名称
|
||||
DB_HOST=10.100.51.51
|
||||
DB_USER=root
|
||||
DB_PASSWORD=Unis@123
|
||||
DB_NAME=imeeting_dev
|
||||
# 仅供“宿主机直接运行 backend”使用
|
||||
# 如果 backend 运行在 Docker 中,且数据库也在同一 Docker 网络,主机名应填服务名(如 mysql)
|
||||
# 如果是宿主机直接运行 backend,再填写 127.0.0.1 或实际地址
|
||||
DB_HOST=127.0.0.1
|
||||
DB_USER=imeeting
|
||||
DB_PASSWORD=change_this_password
|
||||
DB_NAME=imeeting
|
||||
DB_PORT=3306
|
||||
|
||||
# ==================== Redis配置 ====================
|
||||
# Docker环境使用容器名称
|
||||
REDIS_HOST=10.100.51.51
|
||||
# 如果 backend 运行在 Docker 中,且 Redis 也在同一 Docker 网络,主机名应填服务名(如 redis)
|
||||
# 如果是宿主机直接运行 backend,再填写 127.0.0.1 或实际地址
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
REDIS_PASSWORD=Unis@123
|
||||
REDIS_PASSWORD=change_this_password
|
||||
|
||||
# ==================== API配置 ====================
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8001
|
||||
API_PORT=8000
|
||||
|
||||
# ==================== 应用配置 ====================
|
||||
# 应用访问地址(用于生成外部链接、二维码等)
|
||||
# 开发环境: http://localhost
|
||||
# 生产环境: https://your-domain.com
|
||||
BASE_URL=http://imeeting.unisspace.com
|
||||
|
||||
# ==================== LLM配置 ====================
|
||||
# 通义千问API密钥(请替换为实际密钥)
|
||||
QWEN_API_KEY=sk-c2bf06ea56b4491ea3d1e37fdb472b8f
|
||||
# 直接运行 backend 时可在这里配置 BASE_URL;
|
||||
# Docker 容器部署时,优先使用仓库根目录 .env 中的 BASE_URL。
|
||||
# 使用云端音频转录时,必须填写云端可以访问到的公网地址,且不要以 / 结尾。
|
||||
# BASE_URL=https://your-domain.com
|
||||
|
||||
|
||||
# ==================== 转录轮询配置 ====================
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
uploads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ COPY requirements.txt .
|
|||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
curl \
|
||||
ffmpeg \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& pip install --index-url https://mirrors.aliyun.com/pypi/simple --no-cache-dir -r requirements.txt \
|
||||
|
|
|
|||
|
|
@ -1,189 +1,83 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.core.auth import get_current_admin_user, get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.database import get_db_connection
|
||||
from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo, UpdateRolePermissionsRequest, RoleInfo
|
||||
from typing import List
|
||||
from app.models.models import (
|
||||
CreateMenuRequest,
|
||||
CreateRoleRequest,
|
||||
UpdateMenuRequest,
|
||||
UpdateRolePermissionsRequest,
|
||||
UpdateRoleRequest,
|
||||
)
|
||||
import app.services.admin_service as admin_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ========== 菜单权限管理接口 ==========
|
||||
|
||||
@router.get("/admin/menus")
|
||||
async def get_all_menus(current_user=Depends(get_current_admin_user)):
|
||||
"""
|
||||
获取所有菜单列表
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
|
||||
parent_id, sort_order, is_active, description, created_at, updated_at
|
||||
FROM menus
|
||||
ORDER BY sort_order ASC, menu_id ASC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
menus = cursor.fetchall()
|
||||
return admin_service.get_all_menus()
|
||||
|
||||
menu_list = [MenuInfo(**menu) for menu in menus]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取菜单列表成功",
|
||||
data=MenuListResponse(menus=menu_list, total=len(menu_list))
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
|
||||
@router.post("/admin/menus")
|
||||
async def create_menu(request: CreateMenuRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_service.create_menu(request)
|
||||
|
||||
|
||||
@router.put("/admin/menus/{menu_id}")
|
||||
async def update_menu(menu_id: int, request: UpdateMenuRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_service.update_menu(menu_id, request)
|
||||
|
||||
|
||||
@router.delete("/admin/menus/{menu_id}")
|
||||
async def delete_menu(menu_id: int, current_user=Depends(get_current_admin_user)):
|
||||
return admin_service.delete_menu(menu_id)
|
||||
|
||||
|
||||
@router.get("/admin/roles")
|
||||
async def get_all_roles(current_user=Depends(get_current_admin_user)):
|
||||
"""
|
||||
获取所有角色列表及其权限统计
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
return admin_service.get_all_roles()
|
||||
|
||||
# 查询所有角色及其权限数量
|
||||
query = """
|
||||
SELECT r.role_id, r.role_name, r.created_at,
|
||||
COUNT(rmp.menu_id) as menu_count
|
||||
FROM roles r
|
||||
LEFT JOIN role_menu_permissions rmp ON r.role_id = rmp.role_id
|
||||
GROUP BY r.role_id
|
||||
ORDER BY r.role_id ASC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
roles = cursor.fetchall()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取角色列表成功",
|
||||
data={"roles": roles, "total": len(roles)}
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
|
||||
@router.post("/admin/roles")
|
||||
async def create_role(request: CreateRoleRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_service.create_role(request)
|
||||
|
||||
|
||||
@router.put("/admin/roles/{role_id}")
|
||||
async def update_role(role_id: int, request: UpdateRoleRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_service.update_role(role_id, request)
|
||||
|
||||
|
||||
@router.get("/admin/roles/{role_id}/users")
|
||||
async def get_role_users(
|
||||
role_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(10, ge=1, le=100),
|
||||
current_user=Depends(get_current_admin_user),
|
||||
):
|
||||
return admin_service.get_role_users(role_id, page, size)
|
||||
|
||||
|
||||
@router.get("/admin/roles/permissions/all")
|
||||
async def get_all_role_permissions(current_user=Depends(get_current_admin_user)):
|
||||
return admin_service.get_all_role_permissions()
|
||||
|
||||
|
||||
@router.get("/admin/roles/{role_id}/permissions")
|
||||
async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)):
|
||||
"""
|
||||
获取指定角色的菜单权限
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
return admin_service.get_role_permissions(role_id)
|
||||
|
||||
# 检查角色是否存在
|
||||
cursor.execute("SELECT role_id, role_name FROM roles WHERE role_id = %s", (role_id,))
|
||||
role = cursor.fetchone()
|
||||
if not role:
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
# 查询该角色的所有菜单权限
|
||||
query = """
|
||||
SELECT menu_id
|
||||
FROM role_menu_permissions
|
||||
WHERE role_id = %s
|
||||
"""
|
||||
cursor.execute(query, (role_id,))
|
||||
permissions = cursor.fetchall()
|
||||
|
||||
menu_ids = [p['menu_id'] for p in permissions]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取角色权限成功",
|
||||
data=RolePermissionInfo(
|
||||
role_id=role['role_id'],
|
||||
role_name=role['role_name'],
|
||||
menu_ids=menu_ids
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
|
||||
|
||||
@router.put("/admin/roles/{role_id}/permissions")
|
||||
async def update_role_permissions(
|
||||
role_id: int,
|
||||
request: UpdateRolePermissionsRequest,
|
||||
current_user=Depends(get_current_admin_user)
|
||||
current_user=Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
更新指定角色的菜单权限
|
||||
只有管理员才能访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
return admin_service.update_role_permissions(role_id, request)
|
||||
|
||||
# 检查角色是否存在
|
||||
cursor.execute("SELECT role_id FROM roles WHERE role_id = %s", (role_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
# 验证所有menu_id是否有效
|
||||
if request.menu_ids:
|
||||
format_strings = ','.join(['%s'] * len(request.menu_ids))
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) as count FROM menus WHERE menu_id IN ({format_strings})",
|
||||
tuple(request.menu_ids)
|
||||
)
|
||||
valid_count = cursor.fetchone()['count']
|
||||
if valid_count != len(request.menu_ids):
|
||||
return create_api_response(code="400", message="包含无效的菜单ID")
|
||||
|
||||
# 删除该角色的所有现有权限
|
||||
cursor.execute("DELETE FROM role_menu_permissions WHERE role_id = %s", (role_id,))
|
||||
|
||||
# 插入新的权限
|
||||
if request.menu_ids:
|
||||
insert_values = [(role_id, menu_id) for menu_id in request.menu_ids]
|
||||
cursor.executemany(
|
||||
"INSERT INTO role_menu_permissions (role_id, menu_id) VALUES (%s, %s)",
|
||||
insert_values
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="更新角色权限成功",
|
||||
data={"role_id": role_id, "menu_count": len(request.menu_ids)}
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}")
|
||||
|
||||
@router.get("/menus/user")
|
||||
async def get_user_menus(current_user=Depends(get_current_user)):
|
||||
"""
|
||||
获取当前用户可访问的菜单列表(用于渲染下拉菜单)
|
||||
所有登录用户都可以访问
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 根据用户的role_id查询可访问的菜单
|
||||
query = """
|
||||
SELECT DISTINCT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
|
||||
m.menu_url, m.menu_type, m.sort_order
|
||||
FROM menus m
|
||||
JOIN role_menu_permissions rmp ON m.menu_id = rmp.menu_id
|
||||
WHERE rmp.role_id = %s AND m.is_active = 1
|
||||
ORDER BY m.sort_order ASC
|
||||
"""
|
||||
cursor.execute(query, (current_user['role_id'],))
|
||||
menus = cursor.fetchall()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户菜单成功",
|
||||
data={"menus": menus}
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}")
|
||||
|
||||
return admin_service.get_user_menus(current_user)
|
||||
|
|
|
|||
|
|
@ -1,248 +1,25 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.core.auth import get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.jwt_service import jwt_service
|
||||
from app.core.config import AUDIO_DIR, REDIS_CONFIG
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
import os
|
||||
import redis
|
||||
import app.services.admin_dashboard_service as admin_dashboard_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Redis 客户端
|
||||
redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
# 常量定义
|
||||
AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg')
|
||||
BYTES_TO_GB = 1024 ** 3
|
||||
|
||||
|
||||
def _build_status_condition(status: str) -> str:
|
||||
"""构建任务状态查询条件"""
|
||||
if status == 'running':
|
||||
return "AND (t.status = 'pending' OR t.status = 'processing')"
|
||||
elif status == 'completed':
|
||||
return "AND t.status = 'completed'"
|
||||
elif status == 'failed':
|
||||
return "AND t.status = 'failed'"
|
||||
return ""
|
||||
|
||||
|
||||
def _get_task_stats_query() -> str:
|
||||
"""获取任务统计的 SQL 查询"""
|
||||
return """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
|
||||
"""
|
||||
|
||||
|
||||
def _get_online_user_count(redis_client) -> int:
|
||||
"""从 Redis 获取在线用户数"""
|
||||
try:
|
||||
token_keys = redis_client.keys("token:*")
|
||||
user_ids = set()
|
||||
for key in token_keys:
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 2:
|
||||
user_ids.add(parts[1])
|
||||
return len(user_ids)
|
||||
except Exception as e:
|
||||
print(f"获取在线用户数失败: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def _calculate_audio_storage() -> Dict[str, float]:
|
||||
"""计算音频文件存储统计"""
|
||||
audio_files_count = 0
|
||||
audio_total_size = 0
|
||||
|
||||
try:
|
||||
if os.path.exists(AUDIO_DIR):
|
||||
for root, _, files in os.walk(AUDIO_DIR):
|
||||
for file in files:
|
||||
if file.endswith(AUDIO_FILE_EXTENSIONS):
|
||||
audio_files_count += 1
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
audio_total_size += os.path.getsize(file_path)
|
||||
except OSError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"统计音频文件失败: {e}")
|
||||
|
||||
return {
|
||||
"audio_files_count": audio_files_count,
|
||||
"audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/dashboard/stats")
|
||||
async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
|
||||
"""获取管理员 Dashboard 统计数据"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 1. 用户统计
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
cursor.execute("SELECT COUNT(*) as total FROM users")
|
||||
total_users = cursor.fetchone()['total']
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM users WHERE created_at >= %s",
|
||||
(today_start,)
|
||||
)
|
||||
today_new_users = cursor.fetchone()['count']
|
||||
|
||||
online_users = _get_online_user_count(redis_client)
|
||||
|
||||
# 2. 会议统计
|
||||
cursor.execute("SELECT COUNT(*) as total FROM meetings")
|
||||
total_meetings = cursor.fetchone()['total']
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
|
||||
(today_start,)
|
||||
)
|
||||
today_new_meetings = cursor.fetchone()['count']
|
||||
|
||||
# 3. 任务统计
|
||||
task_stats_query = _get_task_stats_query()
|
||||
|
||||
# 转录任务
|
||||
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
|
||||
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 总结任务
|
||||
cursor.execute(f"{task_stats_query} FROM llm_tasks")
|
||||
summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 知识库任务
|
||||
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
|
||||
kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 4. 音频存储统计
|
||||
storage_stats = _calculate_audio_storage()
|
||||
|
||||
# 组装返回数据
|
||||
stats = {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"today_new": today_new_users,
|
||||
"online": online_users
|
||||
},
|
||||
"meetings": {
|
||||
"total": total_meetings,
|
||||
"today_new": today_new_meetings
|
||||
},
|
||||
"tasks": {
|
||||
"transcription": {
|
||||
"total": transcription_stats['total'] or 0,
|
||||
"running": transcription_stats['running'] or 0,
|
||||
"completed": transcription_stats['completed'] or 0,
|
||||
"failed": transcription_stats['failed'] or 0
|
||||
},
|
||||
"summary": {
|
||||
"total": summary_stats['total'] or 0,
|
||||
"running": summary_stats['running'] or 0,
|
||||
"completed": summary_stats['completed'] or 0,
|
||||
"failed": summary_stats['failed'] or 0
|
||||
},
|
||||
"knowledge_base": {
|
||||
"total": kb_stats['total'] or 0,
|
||||
"running": kb_stats['running'] or 0,
|
||||
"completed": kb_stats['completed'] or 0,
|
||||
"failed": kb_stats['failed'] or 0
|
||||
}
|
||||
},
|
||||
"storage": storage_stats
|
||||
}
|
||||
|
||||
return create_api_response(code="200", message="获取统计数据成功", data=stats)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取Dashboard统计数据失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}")
|
||||
return await admin_dashboard_service.get_dashboard_stats(current_user)
|
||||
|
||||
|
||||
@router.get("/admin/online-users")
|
||||
async def get_online_users(current_user=Depends(get_current_admin_user)):
|
||||
"""获取在线用户列表"""
|
||||
try:
|
||||
token_keys = redis_client.keys("token:*")
|
||||
|
||||
# 提取用户ID并去重
|
||||
user_tokens = {}
|
||||
for key in token_keys:
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 3:
|
||||
user_id = int(parts[1])
|
||||
token = parts[2]
|
||||
if user_id not in user_tokens:
|
||||
user_tokens[user_id] = []
|
||||
user_tokens[user_id].append({'token': token, 'key': key})
|
||||
|
||||
# 查询用户信息
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
online_users_list = []
|
||||
for user_id, tokens in user_tokens.items():
|
||||
cursor.execute(
|
||||
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
if user:
|
||||
ttl_seconds = redis_client.ttl(tokens[0]['key'])
|
||||
online_users_list.append({
|
||||
**user,
|
||||
'token_count': len(tokens),
|
||||
'ttl_seconds': ttl_seconds,
|
||||
'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0
|
||||
})
|
||||
|
||||
# 按用户ID排序
|
||||
online_users_list.sort(key=lambda x: x['user_id'])
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取在线用户列表成功",
|
||||
data={"users": online_users_list, "total": len(online_users_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取在线用户列表失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}")
|
||||
return await admin_dashboard_service.get_online_users(current_user)
|
||||
|
||||
|
||||
@router.post("/admin/kick-user/{user_id}")
|
||||
async def kick_user(user_id: int, current_user=Depends(get_current_admin_user)):
|
||||
"""踢出用户(撤销该用户的所有 token)"""
|
||||
try:
|
||||
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
|
||||
|
||||
if revoked_count > 0:
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message=f"已踢出用户,撤销了 {revoked_count} 个 token",
|
||||
data={"user_id": user_id, "revoked_count": revoked_count}
|
||||
)
|
||||
else:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="该用户当前不在线或未找到 token"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"踢出用户失败: {e}")
|
||||
return create_api_response(code="500", message=f"踢出用户失败: {str(e)}")
|
||||
return await admin_dashboard_service.kick_user(user_id, current_user)
|
||||
|
||||
|
||||
@router.get("/admin/tasks/monitor")
|
||||
|
|
@ -252,207 +29,19 @@ async def monitor_tasks(
|
|||
limit: int = Query(20, ge=1, le=100, description="返回数量限制"),
|
||||
current_user=Depends(get_current_admin_user)
|
||||
):
|
||||
"""监控任务进度"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
tasks = []
|
||||
status_condition = _build_status_condition(status)
|
||||
|
||||
# 转录任务
|
||||
if task_type in ['all', 'transcription']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'transcription' as task_type,
|
||||
t.meeting_id,
|
||||
m.title as meeting_title,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.completed_at,
|
||||
u.username as creator_name
|
||||
FROM transcript_tasks t
|
||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN users u ON m.user_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 总结任务
|
||||
if task_type in ['all', 'summary']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'summary' as task_type,
|
||||
t.meeting_id,
|
||||
m.title as meeting_title,
|
||||
t.status,
|
||||
NULL as progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.completed_at,
|
||||
u.username as creator_name
|
||||
FROM llm_tasks t
|
||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN users u ON m.user_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 知识库任务
|
||||
if task_type in ['all', 'knowledge_base']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'knowledge_base' as task_type,
|
||||
t.kb_id as meeting_id,
|
||||
k.title as meeting_title,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
u.username as creator_name
|
||||
FROM knowledge_base_tasks t
|
||||
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
|
||||
LEFT JOIN users u ON k.creator_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 按创建时间排序并限制返回数量
|
||||
tasks.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
tasks = tasks[:limit]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取任务监控数据成功",
|
||||
data={"tasks": tasks, "total": len(tasks)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取任务监控数据失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}")
|
||||
return await admin_dashboard_service.monitor_tasks(task_type, status, limit, current_user)
|
||||
|
||||
|
||||
@router.get("/admin/system/resources")
|
||||
async def get_system_resources(current_user=Depends(get_current_admin_user)):
|
||||
"""获取服务器资源使用情况"""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
# CPU 使用率
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
|
||||
# 内存使用情况
|
||||
memory = psutil.virtual_memory()
|
||||
memory_total_gb = round(memory.total / BYTES_TO_GB, 2)
|
||||
memory_used_gb = round(memory.used / BYTES_TO_GB, 2)
|
||||
|
||||
# 磁盘使用情况
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_total_gb = round(disk.total / BYTES_TO_GB, 2)
|
||||
disk_used_gb = round(disk.used / BYTES_TO_GB, 2)
|
||||
|
||||
resources = {
|
||||
"cpu": {
|
||||
"percent": cpu_percent,
|
||||
"count": cpu_count
|
||||
},
|
||||
"memory": {
|
||||
"total_gb": memory_total_gb,
|
||||
"used_gb": memory_used_gb,
|
||||
"percent": memory.percent
|
||||
},
|
||||
"disk": {
|
||||
"total_gb": disk_total_gb,
|
||||
"used_gb": disk_used_gb,
|
||||
"percent": disk.percent
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return create_api_response(code="200", message="获取系统资源成功", data=resources)
|
||||
|
||||
except ImportError:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message="psutil 库未安装,请运行: pip install psutil"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"获取系统资源失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}")
|
||||
return await admin_dashboard_service.get_system_resources(current_user)
|
||||
|
||||
|
||||
@router.get("/admin/user-stats")
|
||||
async def get_user_stats(current_user=Depends(get_current_admin_user)):
|
||||
"""获取用户统计列表"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
return await admin_dashboard_service.get_user_stats(current_user)
|
||||
|
||||
# 查询所有用户及其会议统计和最后登录时间(排除没有会议的用户)
|
||||
query = """
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.caption,
|
||||
u.created_at,
|
||||
(SELECT MAX(created_at) FROM user_logs
|
||||
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
|
||||
COUNT(DISTINCT m.meeting_id) as meeting_count,
|
||||
COALESCE(SUM(af.duration), 0) as total_duration_seconds
|
||||
FROM users u
|
||||
INNER JOIN meetings m ON u.user_id = m.user_id
|
||||
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
|
||||
GROUP BY u.user_id, u.username, u.caption, u.created_at
|
||||
HAVING meeting_count > 0
|
||||
ORDER BY u.user_id ASC
|
||||
"""
|
||||
|
||||
cursor.execute(query)
|
||||
users = cursor.fetchall()
|
||||
|
||||
# 格式化返回数据
|
||||
users_list = []
|
||||
for user in users:
|
||||
total_seconds = int(user['total_duration_seconds']) if user['total_duration_seconds'] else 0
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
|
||||
users_list.append({
|
||||
'user_id': user['user_id'],
|
||||
'username': user['username'],
|
||||
'caption': user['caption'],
|
||||
'created_at': user['created_at'].isoformat() if user['created_at'] else None,
|
||||
'last_login_time': user['last_login_time'].isoformat() if user['last_login_time'] else None,
|
||||
'meeting_count': user['meeting_count'],
|
||||
'total_duration_seconds': total_seconds,
|
||||
'total_duration_formatted': f"{hours}h {minutes}m" if total_seconds > 0 else '-'
|
||||
})
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户统计成功",
|
||||
data={"users": users_list, "total": len(users_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户统计失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return create_api_response(code="500", message=f"获取用户统计失败: {str(e)}")
|
||||
@router.post("/admin/tasks/{task_type}/{task_id}/retry")
|
||||
async def retry_task(task_type: str, task_id: str, current_user=Depends(get_current_admin_user)):
|
||||
return await admin_dashboard_service.retry_task(task_type, task_id, current_user)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.auth import get_current_admin_user
|
||||
import app.services.admin_settings_service as admin_settings_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ParameterUpsertRequest(BaseModel):
|
||||
param_key: str
|
||||
param_name: str
|
||||
param_value: str
|
||||
value_type: str = "string"
|
||||
category: str = "system"
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class LLMModelUpsertRequest(BaseModel):
|
||||
model_code: str
|
||||
model_name: str
|
||||
provider: str | None = None
|
||||
endpoint_url: str | None = None
|
||||
api_key: str | None = None
|
||||
llm_model_name: str
|
||||
llm_timeout: int = 120
|
||||
llm_temperature: float = 0.7
|
||||
llm_top_p: float = 0.9
|
||||
llm_max_tokens: int = 2048
|
||||
llm_system_prompt: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class AudioModelUpsertRequest(BaseModel):
|
||||
model_code: str
|
||||
model_name: str
|
||||
audio_scene: str
|
||||
provider: str | None = None
|
||||
endpoint_url: str | None = None
|
||||
api_key: str | None = None
|
||||
request_timeout_seconds: int = 300
|
||||
extra_config: dict[str, Any] | None = None
|
||||
hot_word_group_id: int | None = None
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class LLMModelTestRequest(LLMModelUpsertRequest):
|
||||
test_prompt: str | None = None
|
||||
|
||||
|
||||
class AudioModelTestRequest(AudioModelUpsertRequest):
|
||||
test_file_url: str | None = None
|
||||
|
||||
|
||||
@router.get("/admin/parameters")
|
||||
async def list_parameters(
|
||||
category: str | None = Query(None),
|
||||
keyword: str | None = Query(None),
|
||||
current_user=Depends(get_current_admin_user),
|
||||
):
|
||||
return admin_settings_service.list_parameters(category, keyword)
|
||||
|
||||
|
||||
@router.get("/admin/parameters/{param_key}")
|
||||
async def get_parameter(param_key: str, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.get_parameter(param_key)
|
||||
|
||||
|
||||
@router.post("/admin/parameters")
|
||||
async def create_parameter(request: ParameterUpsertRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.create_parameter(request)
|
||||
|
||||
|
||||
@router.put("/admin/parameters/{param_key}")
|
||||
async def update_parameter(
|
||||
param_key: str,
|
||||
request: ParameterUpsertRequest,
|
||||
current_user=Depends(get_current_admin_user),
|
||||
):
|
||||
return admin_settings_service.update_parameter(param_key, request)
|
||||
|
||||
|
||||
@router.delete("/admin/parameters/{param_key}")
|
||||
async def delete_parameter(param_key: str, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.delete_parameter(param_key)
|
||||
|
||||
|
||||
@router.get("/admin/model-configs/llm")
|
||||
async def list_llm_model_configs(current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.list_llm_model_configs()
|
||||
|
||||
|
||||
@router.post("/admin/model-configs/llm")
|
||||
async def create_llm_model_config(request: LLMModelUpsertRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.create_llm_model_config(request)
|
||||
|
||||
|
||||
@router.put("/admin/model-configs/llm/{model_code}")
|
||||
async def update_llm_model_config(
|
||||
model_code: str,
|
||||
request: LLMModelUpsertRequest,
|
||||
current_user=Depends(get_current_admin_user),
|
||||
):
|
||||
return admin_settings_service.update_llm_model_config(model_code, request)
|
||||
|
||||
|
||||
@router.get("/admin/model-configs/audio")
|
||||
async def list_audio_model_configs(
|
||||
scene: str = Query("all"),
|
||||
current_user=Depends(get_current_admin_user),
|
||||
):
|
||||
return admin_settings_service.list_audio_model_configs(scene)
|
||||
|
||||
|
||||
@router.post("/admin/model-configs/audio")
|
||||
async def create_audio_model_config(request: AudioModelUpsertRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.create_audio_model_config(request)
|
||||
|
||||
|
||||
@router.put("/admin/model-configs/audio/{model_code}")
|
||||
async def update_audio_model_config(
|
||||
model_code: str,
|
||||
request: AudioModelUpsertRequest,
|
||||
current_user=Depends(get_current_admin_user),
|
||||
):
|
||||
return admin_settings_service.update_audio_model_config(model_code, request)
|
||||
|
||||
|
||||
@router.delete("/admin/model-configs/llm/{model_code}")
|
||||
async def delete_llm_model_config(model_code: str, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.delete_llm_model_config(model_code)
|
||||
|
||||
|
||||
@router.delete("/admin/model-configs/audio/{model_code}")
|
||||
async def delete_audio_model_config(model_code: str, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.delete_audio_model_config(model_code)
|
||||
|
||||
|
||||
@router.post("/admin/model-configs/llm/test")
|
||||
async def test_llm_model_config(request: LLMModelTestRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.test_llm_model_config(request)
|
||||
|
||||
|
||||
@router.post("/admin/model-configs/audio/test")
|
||||
async def test_audio_model_config(request: AudioModelTestRequest, current_user=Depends(get_current_admin_user)):
|
||||
return admin_settings_service.test_audio_model_config(request)
|
||||
|
||||
|
||||
@router.get("/system-config/public")
|
||||
async def get_public_system_config():
|
||||
return admin_settings_service.get_public_system_config()
|
||||
|
|
@ -4,9 +4,7 @@ from app.core.config import BASE_DIR, AUDIO_DIR, TEMP_UPLOAD_DIR
|
|||
from app.core.auth import get_current_user
|
||||
from app.core.response import create_api_response
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.async_meeting_service import async_meeting_service
|
||||
from app.services.audio_service import handle_audio_upload
|
||||
from app.utils.audio_parser import get_audio_duration
|
||||
from app.services.audio_upload_task_service import audio_upload_task_service
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -456,55 +454,25 @@ async def complete_upload(
|
|||
}
|
||||
)
|
||||
|
||||
# 6. 获取文件信息
|
||||
# 6. 提交后台任务,异步执行预处理和转录启动
|
||||
full_path = BASE_DIR / file_path.lstrip('/')
|
||||
file_size = full_path.stat().st_size
|
||||
file_name = full_path.name
|
||||
|
||||
# 6.5 获取音频时长
|
||||
audio_duration = 0
|
||||
try:
|
||||
audio_duration = get_audio_duration(str(full_path))
|
||||
print(f"音频时长: {audio_duration}秒")
|
||||
except Exception as e:
|
||||
print(f"警告: 获取音频时长失败,但不影响后续流程: {e}")
|
||||
|
||||
# 7. 调用 audio_service 处理文件(数据库更新、启动转录和总结)
|
||||
result = handle_audio_upload(
|
||||
file_path=file_path,
|
||||
file_name=file_name,
|
||||
file_size=file_size,
|
||||
transcription_task_id = audio_upload_task_service.enqueue_upload_processing(
|
||||
meeting_id=request.meeting_id,
|
||||
original_file_path=file_path,
|
||||
current_user=current_user,
|
||||
auto_summarize=request.auto_summarize,
|
||||
background_tasks=background_tasks,
|
||||
prompt_id=request.prompt_id, # 传递提示词模版ID
|
||||
duration=audio_duration # 传递时长参数
|
||||
prompt_id=request.prompt_id,
|
||||
)
|
||||
|
||||
# 如果处理失败,返回错误
|
||||
if not result["success"]:
|
||||
return result["response"]
|
||||
|
||||
# 8. 返回成功响应
|
||||
transcription_task_id = result["transcription_task_id"]
|
||||
message_suffix = ""
|
||||
if transcription_task_id:
|
||||
if request.auto_summarize:
|
||||
message_suffix = ",正在进行转录和总结"
|
||||
else:
|
||||
message_suffix = ",正在进行转录"
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="音频上传完成" + message_suffix,
|
||||
message="音频上传完成,后台正在处理音频" + ("并准备总结" if request.auto_summarize else ""),
|
||||
data={
|
||||
"meeting_id": request.meeting_id,
|
||||
"file_path": file_path,
|
||||
"file_size": file_size,
|
||||
"duration": audio_duration,
|
||||
"task_id": transcription_task_id,
|
||||
"task_status": "pending" if transcription_task_id else None,
|
||||
"task_status": "processing",
|
||||
"background_processing": True,
|
||||
"auto_summarize": request.auto_summarize
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|||
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import get_db_connection
|
||||
from app.models.models import LoginRequest, LoginResponse
|
||||
from app.models.models import LoginRequest, LoginResponse, UserInfo
|
||||
from app.services.jwt_service import jwt_service
|
||||
from app.core.response import create_api_response
|
||||
|
||||
|
|
@ -23,7 +23,21 @@ def login(request_body: LoginRequest, request: Request):
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
query = "SELECT user_id, username, caption, avatar_url, email, password_hash, role_id FROM users WHERE username = %s"
|
||||
query = """
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.caption,
|
||||
u.avatar_url,
|
||||
u.email,
|
||||
u.password_hash,
|
||||
u.role_id,
|
||||
u.created_at,
|
||||
COALESCE(r.role_name, '普通用户') AS role_name
|
||||
FROM sys_users u
|
||||
LEFT JOIN sys_roles r ON r.role_id = u.role_id
|
||||
WHERE u.username = %s
|
||||
"""
|
||||
cursor.execute(query, (request_body.username,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
|
|
@ -67,19 +81,23 @@ def login(request_body: LoginRequest, request: Request):
|
|||
print(f"Failed to log user login: {e}")
|
||||
|
||||
login_response_data = LoginResponse(
|
||||
user_id=user['user_id'],
|
||||
username=user['username'],
|
||||
caption=user['caption'],
|
||||
avatar_url=user['avatar_url'],
|
||||
email=user['email'],
|
||||
token=token,
|
||||
role_id=user['role_id']
|
||||
user=UserInfo(
|
||||
user_id=user['user_id'],
|
||||
username=user['username'],
|
||||
caption=user['caption'],
|
||||
email=user.get('email'),
|
||||
role_id=user['role_id'],
|
||||
role_name=user.get('role_name') or '普通用户',
|
||||
avatar_url=user.get('avatar_url'),
|
||||
created_at=user['created_at']
|
||||
)
|
||||
)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="登录成功",
|
||||
data=login_response_data.dict()
|
||||
data=login_response_data.model_dump()
|
||||
)
|
||||
|
||||
@router.post("/auth/logout")
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ async def get_client_downloads(
|
|||
platform_code: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
page: int = 1,
|
||||
size: int = 50
|
||||
size: int = 50,
|
||||
current_user: dict = Depends(get_current_admin_user)
|
||||
):
|
||||
"""
|
||||
获取客户端下载列表(管理后台接口)
|
||||
|
|
@ -102,7 +103,7 @@ async def get_latest_clients():
|
|||
query = """
|
||||
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
|
||||
FROM client_downloads cd
|
||||
LEFT JOIN dict_data dd ON cd.platform_code = dd.dict_code
|
||||
LEFT JOIN sys_dict_data dd ON cd.platform_code = dd.dict_code
|
||||
AND dd.dict_type = 'client_platform'
|
||||
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
|
||||
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ async def get_dict_types():
|
|||
|
||||
query = """
|
||||
SELECT DISTINCT dict_type
|
||||
FROM dict_data
|
||||
FROM sys_dict_data
|
||||
WHERE status = 1
|
||||
ORDER BY dict_type
|
||||
"""
|
||||
|
|
@ -99,7 +99,7 @@ async def get_dict_data_by_type(dict_type: str, parent_code: Optional[str] = Non
|
|||
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||
label_cn, label_en, sort_order, extension_attr,
|
||||
is_default, status, create_time
|
||||
FROM dict_data
|
||||
FROM sys_dict_data
|
||||
WHERE dict_type = %s AND status = 1
|
||||
"""
|
||||
params = [dict_type]
|
||||
|
|
@ -187,7 +187,7 @@ async def get_dict_data_by_code(dict_type: str, dict_code: str):
|
|||
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||
label_cn, label_en, sort_order, extension_attr,
|
||||
is_default, status, create_time, update_time
|
||||
FROM dict_data
|
||||
FROM sys_dict_data
|
||||
WHERE dict_type = %s AND dict_code = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
|
|
@ -246,7 +246,7 @@ async def create_dict_data(
|
|||
|
||||
# 检查是否已存在
|
||||
cursor.execute(
|
||||
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
|
||||
"SELECT id FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s",
|
||||
(request.dict_type, request.dict_code)
|
||||
)
|
||||
if cursor.fetchone():
|
||||
|
|
@ -258,7 +258,7 @@ async def create_dict_data(
|
|||
|
||||
# 插入数据
|
||||
query = """
|
||||
INSERT INTO dict_data (
|
||||
INSERT INTO sys_dict_data (
|
||||
dict_type, dict_code, parent_code, label_cn, label_en,
|
||||
sort_order, extension_attr, is_default, status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
|
|
@ -319,7 +319,7 @@ async def update_dict_data(
|
|||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 检查是否存在
|
||||
cursor.execute("SELECT * FROM dict_data WHERE id = %s", (id,))
|
||||
cursor.execute("SELECT * FROM sys_dict_data WHERE id = %s", (id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
|
|
@ -369,7 +369,7 @@ async def update_dict_data(
|
|||
|
||||
# 执行更新
|
||||
update_query = f"""
|
||||
UPDATE dict_data
|
||||
UPDATE sys_dict_data
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
|
@ -404,7 +404,7 @@ async def delete_dict_data(
|
|||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 检查是否存在
|
||||
cursor.execute("SELECT dict_code FROM dict_data WHERE id = %s", (id,))
|
||||
cursor.execute("SELECT dict_code FROM sys_dict_data WHERE id = %s", (id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
cursor.close()
|
||||
|
|
@ -415,7 +415,7 @@ async def delete_dict_data(
|
|||
|
||||
# 检查是否有子节点
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM dict_data WHERE parent_code = %s",
|
||||
"SELECT COUNT(*) as count FROM sys_dict_data WHERE parent_code = %s",
|
||||
(existing['dict_code'],)
|
||||
)
|
||||
if cursor.fetchone()['count'] > 0:
|
||||
|
|
@ -438,7 +438,7 @@ async def delete_dict_data(
|
|||
)
|
||||
|
||||
# 执行删除
|
||||
cursor.execute("DELETE FROM dict_data WHERE id = %s", (id,))
|
||||
cursor.execute("DELETE FROM sys_dict_data WHERE id = %s", (id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ async def get_external_apps(
|
|||
list_query = f"""
|
||||
SELECT ea.*, u.username as creator_username
|
||||
FROM external_apps ea
|
||||
LEFT JOIN users u ON ea.created_by = u.user_id
|
||||
LEFT JOIN sys_users u ON ea.created_by = u.user_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY ea.sort_order ASC, ea.created_at DESC
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_admin_user
|
||||
from app.core.response import create_api_response
|
||||
from app.core.config import QWEN_API_KEY
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import json
|
||||
import dashscope
|
||||
from dashscope.audio.asr import VocabularyService
|
||||
from datetime import datetime
|
||||
|
|
@ -14,48 +12,76 @@ from http import HTTPStatus
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
class HotWordItem(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
weight: int
|
||||
lang: str
|
||||
status: int
|
||||
create_time: datetime
|
||||
update_time: datetime
|
||||
|
||||
class CreateHotWordRequest(BaseModel):
|
||||
def _resolve_dashscope_api_key() -> str:
|
||||
audio_config = SystemConfigService.get_active_audio_model_config("asr") or {}
|
||||
api_key = str(audio_config.get("api_key") or "").strip()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=500, detail="未在启用的 ASR 模型配置中设置 DashScope API Key")
|
||||
return api_key
|
||||
|
||||
|
||||
# ── Request Models ──────────────────────────────────────────
|
||||
|
||||
class CreateGroupRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
status: int = 1
|
||||
|
||||
|
||||
class UpdateGroupRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
|
||||
|
||||
class CreateItemRequest(BaseModel):
|
||||
text: str
|
||||
weight: int = 4
|
||||
lang: str = "zh"
|
||||
status: int = 1
|
||||
|
||||
class UpdateHotWordRequest(BaseModel):
|
||||
|
||||
class UpdateItemRequest(BaseModel):
|
||||
text: Optional[str] = None
|
||||
weight: Optional[int] = None
|
||||
lang: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
|
||||
@router.get("/admin/hot-words", response_model=dict)
|
||||
async def list_hot_words(current_user: dict = Depends(get_current_admin_user)):
|
||||
"""获取热词列表"""
|
||||
|
||||
# ── Hot-Word Group CRUD ─────────────────────────────────────
|
||||
|
||||
@router.get("/admin/hot-word-groups", response_model=dict)
|
||||
async def list_groups(current_user: dict = Depends(get_current_admin_user)):
|
||||
"""列表(含每组热词数量统计)"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM hot_words ORDER BY update_time DESC")
|
||||
items = cursor.fetchall()
|
||||
cursor.execute("""
|
||||
SELECT g.*,
|
||||
COUNT(i.id) AS item_count,
|
||||
SUM(CASE WHEN i.status = 1 THEN 1 ELSE 0 END) AS enabled_item_count
|
||||
FROM hot_word_group g
|
||||
LEFT JOIN hot_word_item i ON i.group_id = g.id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.update_time DESC
|
||||
""")
|
||||
groups = cursor.fetchall()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="获取成功", data=items)
|
||||
return create_api_response(code="200", message="获取成功", data=groups)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取失败: {str(e)}")
|
||||
|
||||
@router.post("/admin/hot-words", response_model=dict)
|
||||
async def create_hot_word(request: CreateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""创建热词"""
|
||||
|
||||
@router.post("/admin/hot-word-groups", response_model=dict)
|
||||
async def create_group(request: CreateGroupRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
query = "INSERT INTO hot_words (text, weight, lang, status) VALUES (%s, %s, %s, %s)"
|
||||
cursor.execute(query, (request.text, request.weight, request.lang, request.status))
|
||||
cursor.execute(
|
||||
"INSERT INTO hot_word_group (name, description, status) VALUES (%s, %s, %s)",
|
||||
(request.name, request.description, request.status),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
|
|
@ -63,111 +89,207 @@ async def create_hot_word(request: CreateHotWordRequest, current_user: dict = De
|
|||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"创建失败: {str(e)}")
|
||||
|
||||
@router.put("/admin/hot-words/{id}", response_model=dict)
|
||||
async def update_hot_word(id: int, request: UpdateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""更新热词"""
|
||||
|
||||
@router.put("/admin/hot-word-groups/{id}", response_model=dict)
|
||||
async def update_group(id: int, request: UpdateGroupRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
update_fields = []
|
||||
params = []
|
||||
if request.text is not None:
|
||||
update_fields.append("text = %s")
|
||||
params.append(request.text)
|
||||
if request.weight is not None:
|
||||
update_fields.append("weight = %s")
|
||||
params.append(request.weight)
|
||||
if request.lang is not None:
|
||||
update_fields.append("lang = %s")
|
||||
params.append(request.lang)
|
||||
fields, params = [], []
|
||||
if request.name is not None:
|
||||
fields.append("name = %s"); params.append(request.name)
|
||||
if request.description is not None:
|
||||
fields.append("description = %s"); params.append(request.description)
|
||||
if request.status is not None:
|
||||
update_fields.append("status = %s")
|
||||
params.append(request.status)
|
||||
|
||||
if not update_fields:
|
||||
fields.append("status = %s"); params.append(request.status)
|
||||
if not fields:
|
||||
return create_api_response(code="400", message="无更新内容")
|
||||
|
||||
query = f"UPDATE hot_words SET {', '.join(update_fields)} WHERE id = %s"
|
||||
params.append(id)
|
||||
cursor.execute(query, tuple(params))
|
||||
cursor.execute(f"UPDATE hot_word_group SET {', '.join(fields)} WHERE id = %s", tuple(params))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="更新成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新失败: {str(e)}")
|
||||
|
||||
@router.delete("/admin/hot-words/{id}", response_model=dict)
|
||||
async def delete_hot_word(id: int, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""删除热词"""
|
||||
|
||||
@router.delete("/admin/hot-word-groups/{id}", response_model=dict)
|
||||
async def delete_group(id: int, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""删除组(级联删除条目),同时清除关联的 audio_model_config"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM hot_words WHERE id = %s", (id,))
|
||||
# 清除引用该组的音频模型配置
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE audio_model_config
|
||||
SET hot_word_group_id = NULL,
|
||||
extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id')
|
||||
WHERE hot_word_group_id = %s
|
||||
""",
|
||||
(id,),
|
||||
)
|
||||
cursor.execute("DELETE FROM hot_word_item WHERE group_id = %s", (id,))
|
||||
cursor.execute("DELETE FROM hot_word_group WHERE id = %s", (id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="删除成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除失败: {str(e)}")
|
||||
|
||||
@router.post("/admin/hot-words/sync", response_model=dict)
|
||||
async def sync_hot_words(current_user: dict = Depends(get_current_admin_user)):
|
||||
"""同步热词到阿里云 DashScope"""
|
||||
try:
|
||||
dashscope.api_key = QWEN_API_KEY
|
||||
|
||||
# 1. 获取所有启用的热词
|
||||
@router.post("/admin/hot-word-groups/{id}/sync", response_model=dict)
|
||||
async def sync_group(id: int, current_user: dict = Depends(get_current_admin_user)):
|
||||
"""同步指定组到阿里云 DashScope"""
|
||||
try:
|
||||
dashscope.api_key = _resolve_dashscope_api_key()
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT text, weight, lang FROM hot_words WHERE status = 1")
|
||||
hot_words = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
# 2. 获取现有的 vocabulary_id
|
||||
existing_vocab_id = SystemConfigService.get_asr_vocabulary_id()
|
||||
# 获取组信息
|
||||
cursor.execute("SELECT * FROM hot_word_group WHERE id = %s", (id,))
|
||||
group = cursor.fetchone()
|
||||
if not group:
|
||||
return create_api_response(code="404", message="热词组不存在")
|
||||
|
||||
# 构建热词列表
|
||||
vocabulary_list = [{"text": hw['text'], "weight": hw['weight'], "lang": hw['lang']} for hw in hot_words]
|
||||
# 获取该组下启用的热词
|
||||
cursor.execute(
|
||||
"SELECT text, weight, lang FROM hot_word_item WHERE group_id = %s AND status = 1",
|
||||
(id,),
|
||||
)
|
||||
items = cursor.fetchall()
|
||||
if not items:
|
||||
return create_api_response(code="400", message="该组没有启用的热词可同步")
|
||||
|
||||
if not vocabulary_list:
|
||||
return create_api_response(code="400", message="没有启用的热词可同步")
|
||||
vocabulary_list = [{"text": it["text"], "weight": it["weight"], "lang": it["lang"]} for it in items]
|
||||
|
||||
# ASR 模型名(同步时需要)
|
||||
asr_model_name = SystemConfigService.get_config_attribute('audio_model', 'model', 'paraformer-v2')
|
||||
existing_vocab_id = group.get("vocabulary_id")
|
||||
|
||||
# 3. 调用阿里云 API
|
||||
service = VocabularyService()
|
||||
vocab_id = existing_vocab_id
|
||||
|
||||
try:
|
||||
if existing_vocab_id:
|
||||
# 尝试更新现有的热词表
|
||||
try:
|
||||
service.update_vocabulary(
|
||||
vocabulary_id=existing_vocab_id,
|
||||
vocabulary=vocabulary_list
|
||||
vocabulary=vocabulary_list,
|
||||
)
|
||||
# 更新成功,保持原有ID
|
||||
except Exception as update_error:
|
||||
# 如果更新失败(如资源不存在),尝试创建新的
|
||||
print(f"Update vocabulary failed: {update_error}, trying to create new one.")
|
||||
existing_vocab_id = None # 重置,触发创建逻辑
|
||||
except Exception:
|
||||
existing_vocab_id = None # 更新失败,重建
|
||||
|
||||
if not existing_vocab_id:
|
||||
# 创建新的热词表
|
||||
vocab_id = service.create_vocabulary(
|
||||
prefix='imeeting',
|
||||
target_model='paraformer-v2',
|
||||
vocabulary=vocabulary_list
|
||||
prefix="imeeting",
|
||||
target_model=asr_model_name,
|
||||
vocabulary=vocabulary_list,
|
||||
)
|
||||
|
||||
except Exception as api_error:
|
||||
return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}")
|
||||
return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}")
|
||||
|
||||
# 4. 更新数据库中的 vocabulary_id
|
||||
if vocab_id:
|
||||
SystemConfigService.set_config(
|
||||
SystemConfigService.ASR_VOCABULARY_ID,
|
||||
vocab_id
|
||||
)
|
||||
# 回写 vocabulary_id 到热词组
|
||||
cursor.execute(
|
||||
"UPDATE hot_word_group SET vocabulary_id = %s, last_sync_time = NOW() WHERE id = %s",
|
||||
(vocab_id, id),
|
||||
)
|
||||
|
||||
return create_api_response(code="200", message="同步成功", data={"vocabulary_id": vocab_id})
|
||||
# 更新关联该组的所有 audio_model_config.extra_config.vocabulary_id
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE audio_model_config
|
||||
SET extra_config = JSON_SET(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id', %s)
|
||||
WHERE hot_word_group_id = %s
|
||||
""",
|
||||
(vocab_id, id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="同步成功",
|
||||
data={"vocabulary_id": vocab_id, "synced_count": len(vocabulary_list)},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"同步异常: {str(e)}")
|
||||
|
||||
|
||||
# ── Hot-Word Item CRUD ──────────────────────────────────────
|
||||
|
||||
@router.get("/admin/hot-word-groups/{group_id}/items", response_model=dict)
|
||||
async def list_items(group_id: int, current_user: dict = Depends(get_current_admin_user)):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT * FROM hot_word_item WHERE group_id = %s ORDER BY update_time DESC",
|
||||
(group_id,),
|
||||
)
|
||||
items = cursor.fetchall()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="获取成功", data=items)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/admin/hot-word-groups/{group_id}/items", response_model=dict)
|
||||
async def create_item(group_id: int, request: CreateItemRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO hot_word_item (group_id, text, weight, lang, status) VALUES (%s, %s, %s, %s, %s)",
|
||||
(group_id, request.text, request.weight, request.lang, request.status),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="创建成功", data={"id": new_id})
|
||||
except Exception as e:
|
||||
if "Duplicate entry" in str(e):
|
||||
return create_api_response(code="400", message="该组内已存在相同热词")
|
||||
return create_api_response(code="500", message=f"创建失败: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/admin/hot-word-items/{id}", response_model=dict)
|
||||
async def update_item(id: int, request: UpdateItemRequest, current_user: dict = Depends(get_current_admin_user)):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
fields, params = [], []
|
||||
if request.text is not None:
|
||||
fields.append("text = %s"); params.append(request.text)
|
||||
if request.weight is not None:
|
||||
fields.append("weight = %s"); params.append(request.weight)
|
||||
if request.lang is not None:
|
||||
fields.append("lang = %s"); params.append(request.lang)
|
||||
if request.status is not None:
|
||||
fields.append("status = %s"); params.append(request.status)
|
||||
if not fields:
|
||||
return create_api_response(code="400", message="无更新内容")
|
||||
params.append(id)
|
||||
cursor.execute(f"UPDATE hot_word_item SET {', '.join(fields)} WHERE id = %s", tuple(params))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="更新成功")
|
||||
except Exception as e:
|
||||
if "Duplicate entry" in str(e):
|
||||
return create_api_response(code="400", message="该组内已存在相同热词")
|
||||
return create_api_response(code="500", message=f"更新失败: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/admin/hot-word-items/{id}", response_model=dict)
|
||||
async def delete_item(id: int, current_user: dict = Depends(get_current_admin_user)):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM hot_word_item WHERE id = %s", (id,))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
return create_api_response(code="200", message="删除成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除失败: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def get_knowledge_bases(
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
base_query = "FROM knowledge_bases kb JOIN users u ON kb.creator_id = u.user_id"
|
||||
base_query = "FROM knowledge_bases kb JOIN sys_users u ON kb.creator_id = u.user_id"
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ def get_knowledge_base_detail(
|
|||
kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at,
|
||||
u.username as created_by_name
|
||||
FROM knowledge_bases kb
|
||||
JOIN users u ON kb.creator_id = u.user_id
|
||||
JOIN sys_users u ON kb.creator_id = u.user_id
|
||||
WHERE kb.kb_id = %s
|
||||
"""
|
||||
cursor.execute(query, (kb_id,))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,240 +1,383 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.response import create_api_response
|
||||
from app.models.models import PromptCreate, PromptUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Pydantic Models
|
||||
class PromptIn(BaseModel):
|
||||
name: str
|
||||
task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK'
|
||||
content: str
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
class PromptOut(PromptIn):
|
||||
id: int
|
||||
creator_id: int
|
||||
created_at: str
|
||||
class PromptConfigItem(BaseModel):
|
||||
prompt_id: int
|
||||
is_enabled: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class PromptConfigUpdateRequest(BaseModel):
|
||||
items: List[PromptConfigItem]
|
||||
|
||||
|
||||
def _is_admin(user: dict) -> bool:
|
||||
return int(user.get("role_id") or 0) == 1
|
||||
|
||||
|
||||
def _can_manage_prompt(current_user: dict, row: dict) -> bool:
|
||||
if _is_admin(current_user):
|
||||
return True
|
||||
return int(row.get("creator_id") or 0) == int(current_user["user_id"]) and int(row.get("is_system") or 0) == 0
|
||||
|
||||
class PromptListResponse(BaseModel):
|
||||
prompts: List[PromptOut]
|
||||
total: int
|
||||
|
||||
@router.post("/prompts")
|
||||
def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
||||
"""Create a new prompt."""
|
||||
def create_prompt(
|
||||
prompt: PromptCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a prompt template. Admin can create system prompts, others can only create personal prompts."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
try:
|
||||
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
|
||||
if prompt.is_default:
|
||||
is_admin = _is_admin(current_user)
|
||||
requested_is_system = bool(getattr(prompt, "is_system", False))
|
||||
is_system = 1 if (is_admin and requested_is_system) else 0
|
||||
|
||||
owner_user_id = current_user["user_id"]
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM prompts
|
||||
WHERE task_type = %s
|
||||
AND is_system = %s
|
||||
AND creator_id = %s
|
||||
""",
|
||||
(prompt.task_type, is_system, owner_user_id),
|
||||
)
|
||||
count = (cursor.fetchone() or {}).get("cnt", 0)
|
||||
is_default = 1 if count == 0 else (1 if prompt.is_default else 0)
|
||||
|
||||
if is_default:
|
||||
cursor.execute(
|
||||
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s",
|
||||
(prompt.task_type,)
|
||||
"""
|
||||
UPDATE prompts
|
||||
SET is_default = 0
|
||||
WHERE task_type = %s
|
||||
AND is_system = %s
|
||||
AND creator_id = %s
|
||||
""",
|
||||
(prompt.task_type, is_system, owner_user_id),
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
||||
prompt.is_active, current_user["user_id"])
|
||||
"""
|
||||
INSERT INTO prompts (name, task_type, content, `desc`, is_default, is_active, creator_id, is_system)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
prompt.name,
|
||||
prompt.task_type,
|
||||
prompt.content,
|
||||
prompt.desc,
|
||||
is_default,
|
||||
1 if prompt.is_active else 0,
|
||||
owner_user_id,
|
||||
is_system,
|
||||
),
|
||||
)
|
||||
prompt_id = cursor.lastrowid
|
||||
connection.commit()
|
||||
new_id = cursor.lastrowid
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="提示词创建成功",
|
||||
data={"id": new_id, **prompt.dict()}
|
||||
)
|
||||
return create_api_response(code="200", message="提示词模版创建成功", data={"id": prompt_id})
|
||||
except Exception as e:
|
||||
if "Duplicate entry" in str(e):
|
||||
return create_api_response(code="400", message="提示词名称已存在")
|
||||
return create_api_response(code="500", message=f"创建提示词失败: {e}")
|
||||
connection.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/prompts/active/{task_type}")
|
||||
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)):
|
||||
"""Get all active prompts for a specific task type."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""SELECT id, name, is_default
|
||||
FROM prompts
|
||||
WHERE task_type = %s AND is_active = TRUE
|
||||
ORDER BY is_default DESC, created_at DESC""",
|
||||
(task_type,)
|
||||
)
|
||||
prompts = cursor.fetchall()
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取启用模版列表成功",
|
||||
data={"prompts": prompts}
|
||||
)
|
||||
|
||||
@router.get("/prompts")
|
||||
def get_prompts(
|
||||
task_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
size: int = 12,
|
||||
keyword: Optional[str] = Query(None),
|
||||
is_active: Optional[int] = Query(None),
|
||||
scope: str = Query("mine"), # mine / system / all / accessible
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a paginated list of prompts filtered by current user and optionally by task_type."""
|
||||
"""Get paginated prompt cards. Normal users can only view their own prompts."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 构建 WHERE 条件
|
||||
where_conditions = ["creator_id = %s"]
|
||||
params = [current_user["user_id"]]
|
||||
is_admin = _is_admin(current_user)
|
||||
where_conditions = []
|
||||
params = []
|
||||
|
||||
if scope == "all" and not is_admin:
|
||||
scope = "accessible"
|
||||
|
||||
if scope == "system":
|
||||
where_conditions.append("p.is_system = 1")
|
||||
elif scope == "all":
|
||||
where_conditions.append("(p.is_system = 1 OR p.creator_id = %s)")
|
||||
params.append(current_user["user_id"])
|
||||
elif scope == "accessible":
|
||||
where_conditions.append("((p.is_system = 1 AND p.is_active = 1) OR (p.is_system = 0 AND p.creator_id = %s))")
|
||||
params.append(current_user["user_id"])
|
||||
else:
|
||||
where_conditions.append("p.is_system = 0 AND p.creator_id = %s")
|
||||
params.append(current_user["user_id"])
|
||||
|
||||
if task_type:
|
||||
where_conditions.append("task_type = %s")
|
||||
where_conditions.append("p.task_type = %s")
|
||||
params.append(task_type)
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
if keyword:
|
||||
where_conditions.append("(p.name LIKE %s OR p.`desc` LIKE %s)")
|
||||
like = f"%{keyword}%"
|
||||
params.extend([like, like])
|
||||
if is_active in (0, 1):
|
||||
where_conditions.append("p.is_active = %s")
|
||||
params.append(is_active)
|
||||
|
||||
# 获取总数
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}",
|
||||
tuple(params)
|
||||
)
|
||||
total = cursor.fetchone()['total']
|
||||
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||
|
||||
# 获取分页数据
|
||||
offset = (page - 1) * size
|
||||
cursor.execute(f"SELECT COUNT(*) as total FROM prompts p WHERE {where_clause}", tuple(params))
|
||||
total = (cursor.fetchone() or {}).get("total", 0)
|
||||
|
||||
offset = max(page - 1, 0) * size
|
||||
cursor.execute(
|
||||
f"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
||||
FROM prompts
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
tuple(params + [size, offset])
|
||||
f"""
|
||||
SELECT p.id, p.name, p.task_type, p.content, p.`desc`, p.is_default, p.is_active,
|
||||
p.creator_id, p.is_system, p.created_at,
|
||||
u.caption AS creator_name
|
||||
FROM prompts p
|
||||
LEFT JOIN sys_users u ON u.user_id = p.creator_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY p.is_system DESC, p.task_type ASC, p.is_default DESC, p.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params + [size, offset]),
|
||||
)
|
||||
prompts = cursor.fetchall()
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取提示词列表成功",
|
||||
data={"prompts": prompts, "total": total}
|
||||
data={"prompts": rows, "total": total, "page": page, "size": size},
|
||||
)
|
||||
|
||||
@router.get("/prompts/{prompt_id}")
|
||||
def get_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Get a single prompt by its ID."""
|
||||
|
||||
@router.get("/prompts/active/{task_type}")
|
||||
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Active prompts for task selection.
|
||||
Includes system prompts + personal prompts, and applies user's prompt config ordering.
|
||||
"""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute(
|
||||
"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
||||
FROM prompts WHERE id = %s""",
|
||||
(prompt_id,)
|
||||
"""
|
||||
SELECT p.id, p.name, p.`desc`, p.content, p.is_default, p.is_system, p.creator_id,
|
||||
cfg.is_enabled, cfg.sort_order
|
||||
FROM prompts p
|
||||
LEFT JOIN prompt_config cfg
|
||||
ON cfg.prompt_id = p.id
|
||||
AND cfg.user_id = %s
|
||||
AND cfg.task_type = %s
|
||||
WHERE p.task_type = %s
|
||||
AND p.is_active = 1
|
||||
AND (p.is_system = 1 OR p.creator_id = %s)
|
||||
ORDER BY
|
||||
CASE WHEN cfg.is_enabled = 1 THEN 0 ELSE 1 END,
|
||||
cfg.sort_order ASC,
|
||||
p.is_default DESC,
|
||||
p.created_at DESC
|
||||
""",
|
||||
(current_user["user_id"], task_type, task_type, current_user["user_id"]),
|
||||
)
|
||||
prompt = cursor.fetchone()
|
||||
if not prompt:
|
||||
return create_api_response(code="404", message="提示词不存在")
|
||||
return create_api_response(code="200", message="获取提示词成功", data=prompt)
|
||||
prompts = cursor.fetchall()
|
||||
|
||||
@router.put("/prompts/{prompt_id}")
|
||||
def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
||||
"""Update an existing prompt."""
|
||||
print(f"[UPDATE PROMPT] prompt_id={prompt_id}, type={type(prompt_id)}")
|
||||
print(f"[UPDATE PROMPT] user_id={current_user['user_id']}")
|
||||
print(f"[UPDATE PROMPT] data: name={prompt.name}, task_type={prompt.task_type}, content_len={len(prompt.content)}, is_default={prompt.is_default}, is_active={prompt.is_active}")
|
||||
enabled = [x for x in prompts if x.get("is_enabled") == 1]
|
||||
if enabled:
|
||||
result = enabled
|
||||
else:
|
||||
result = prompts
|
||||
|
||||
return create_api_response(code="200", message="获取启用模版列表成功", data={"prompts": result})
|
||||
|
||||
|
||||
@router.get("/prompts/config/{task_type}")
|
||||
def get_prompt_config(task_type: str, current_user: dict = Depends(get_current_user)):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, name, task_type, content, `desc`, is_default, is_active, is_system, creator_id, created_at
|
||||
FROM prompts
|
||||
WHERE task_type = %s
|
||||
AND is_active = 1
|
||||
AND (is_system = 1 OR creator_id = %s)
|
||||
ORDER BY is_system DESC, is_default DESC, created_at DESC
|
||||
""",
|
||||
(task_type, current_user["user_id"]),
|
||||
)
|
||||
available = cursor.fetchall()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT prompt_id, is_enabled, sort_order
|
||||
FROM prompt_config
|
||||
WHERE user_id = %s AND task_type = %s
|
||||
ORDER BY sort_order ASC, config_id ASC
|
||||
""",
|
||||
(current_user["user_id"], task_type),
|
||||
)
|
||||
configs = cursor.fetchall()
|
||||
|
||||
selected_prompt_ids = [item["prompt_id"] for item in configs if item.get("is_enabled") == 1]
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取提示词配置成功",
|
||||
data={
|
||||
"task_type": task_type,
|
||||
"available_prompts": available,
|
||||
"configs": configs,
|
||||
"selected_prompt_ids": selected_prompt_ids,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/prompts/config/{task_type}")
|
||||
def update_prompt_config(
|
||||
task_type: str,
|
||||
request: PromptConfigUpdateRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
try:
|
||||
# 先检查记录是否存在
|
||||
cursor.execute("SELECT id, creator_id FROM prompts WHERE id = %s", (prompt_id,))
|
||||
existing = cursor.fetchone()
|
||||
print(f"[UPDATE PROMPT] existing record: {existing}")
|
||||
|
||||
if not existing:
|
||||
print(f"[UPDATE PROMPT] Prompt {prompt_id} not found in database")
|
||||
return create_api_response(code="404", message="提示词不存在")
|
||||
|
||||
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
|
||||
if prompt.is_default:
|
||||
print(f"[UPDATE PROMPT] Setting as default, clearing other defaults for task_type={prompt.task_type}")
|
||||
requested_ids = [int(item.prompt_id) for item in request.items if item.is_enabled]
|
||||
if requested_ids:
|
||||
placeholders = ",".join(["%s"] * len(requested_ids))
|
||||
cursor.execute(
|
||||
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s",
|
||||
(prompt.task_type, prompt_id)
|
||||
f"""
|
||||
SELECT id
|
||||
FROM prompts
|
||||
WHERE id IN ({placeholders})
|
||||
AND task_type = %s
|
||||
AND is_active = 1
|
||||
AND (is_system = 1 OR creator_id = %s)
|
||||
""",
|
||||
tuple(requested_ids + [task_type, current_user["user_id"]]),
|
||||
)
|
||||
print(f"[UPDATE PROMPT] Cleared {cursor.rowcount} other default prompts")
|
||||
valid_ids = {row["id"] for row in cursor.fetchall()}
|
||||
invalid_ids = [pid for pid in requested_ids if pid not in valid_ids]
|
||||
if invalid_ids:
|
||||
raise HTTPException(status_code=400, detail=f"存在无效提示词ID: {invalid_ids}")
|
||||
|
||||
print(f"[UPDATE PROMPT] Executing UPDATE query")
|
||||
cursor.execute(
|
||||
"""UPDATE prompts
|
||||
SET name = %s, task_type = %s, content = %s, is_default = %s, is_active = %s
|
||||
WHERE id = %s""",
|
||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
||||
prompt.is_active, prompt_id)
|
||||
"DELETE FROM prompt_config WHERE user_id = %s AND task_type = %s",
|
||||
(current_user["user_id"], task_type),
|
||||
)
|
||||
rows_affected = cursor.rowcount
|
||||
print(f"[UPDATE PROMPT] UPDATE affected {rows_affected} rows (0 means no changes needed)")
|
||||
|
||||
# 注意:rowcount=0 不代表记录不存在,可能是所有字段值都相同
|
||||
# 我们已经在上面确认了记录存在,所以这里直接提交即可
|
||||
ordered = sorted(
|
||||
[item for item in request.items if item.is_enabled],
|
||||
key=lambda x: (x.sort_order, x.prompt_id),
|
||||
)
|
||||
for idx, item in enumerate(ordered):
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO prompt_config (user_id, task_type, prompt_id, is_enabled, sort_order)
|
||||
VALUES (%s, %s, %s, 1, %s)
|
||||
""",
|
||||
(current_user["user_id"], task_type, int(item.prompt_id), idx + 1),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
print(f"[UPDATE PROMPT] Success! Committed changes")
|
||||
return create_api_response(code="200", message="提示词更新成功")
|
||||
return create_api_response(code="200", message="提示词配置保存成功")
|
||||
except HTTPException:
|
||||
connection.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[UPDATE PROMPT] Exception: {type(e).__name__}: {e}")
|
||||
if "Duplicate entry" in str(e):
|
||||
return create_api_response(code="400", message="提示词名称已存在")
|
||||
return create_api_response(code="500", message=f"更新提示词失败: {e}")
|
||||
connection.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/prompts/{prompt_id}")
|
||||
def update_prompt(prompt_id: int, prompt: PromptUpdate, current_user: dict = Depends(get_current_user)):
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
try:
|
||||
cursor.execute("SELECT id, creator_id, task_type, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="模版不存在")
|
||||
if not _can_manage_prompt(current_user, existing):
|
||||
raise HTTPException(status_code=403, detail="无权修改此模版")
|
||||
|
||||
if prompt.is_default is False and existing["is_default"]:
|
||||
raise HTTPException(status_code=400, detail="必须保留一个默认模版,请先设置其他模版为默认")
|
||||
if prompt.is_system is not None and not _is_admin(current_user):
|
||||
raise HTTPException(status_code=403, detail="普通用户不能修改系统提示词属性")
|
||||
|
||||
if prompt.is_default:
|
||||
task_type = prompt.task_type or existing["task_type"]
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE prompts
|
||||
SET is_default = 0
|
||||
WHERE task_type = %s
|
||||
AND is_system = %s
|
||||
AND creator_id = %s
|
||||
""",
|
||||
(task_type, existing.get("is_system", 0), existing["creator_id"]),
|
||||
)
|
||||
if prompt.is_active is False:
|
||||
raise HTTPException(status_code=400, detail="默认模版必须处于启用状态")
|
||||
|
||||
update_fields = []
|
||||
params = []
|
||||
prompt_data = prompt.dict(exclude_unset=True)
|
||||
for field, value in prompt_data.items():
|
||||
if field == "desc":
|
||||
update_fields.append("`desc` = %s")
|
||||
else:
|
||||
update_fields.append(f"{field} = %s")
|
||||
params.append(value)
|
||||
|
||||
if update_fields:
|
||||
params.append(prompt_id)
|
||||
cursor.execute(f"UPDATE prompts SET {', '.join(update_fields)} WHERE id = %s", tuple(params))
|
||||
|
||||
connection.commit()
|
||||
return create_api_response(code="200", message="更新成功")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/prompts/{prompt_id}")
|
||||
def delete_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
|
||||
"""Delete a prompt. Only the creator can delete their own prompts."""
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
# 首先检查提示词是否存在以及是否属于当前用户
|
||||
cursor.execute(
|
||||
"SELECT creator_id FROM prompts WHERE id = %s",
|
||||
(prompt_id,)
|
||||
)
|
||||
prompt = cursor.fetchone()
|
||||
try:
|
||||
cursor.execute("SELECT id, creator_id, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="模版不存在")
|
||||
if not _can_manage_prompt(current_user, existing):
|
||||
raise HTTPException(status_code=403, detail="无权删除此模版")
|
||||
if existing["is_default"]:
|
||||
raise HTTPException(status_code=400, detail="默认模版不允许删除,请先设置其他模版为默认")
|
||||
|
||||
if not prompt:
|
||||
return create_api_response(code="404", message="提示词不存在")
|
||||
|
||||
if prompt['creator_id'] != current_user["user_id"]:
|
||||
return create_api_response(code="403", message="无权删除其他用户的提示词")
|
||||
|
||||
# 检查是否有会议引用了该提示词
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM meetings WHERE prompt_id = %s",
|
||||
(prompt_id,)
|
||||
)
|
||||
meeting_count = cursor.fetchone()['count']
|
||||
|
||||
# 检查是否有知识库引用了该提示词
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM knowledge_bases WHERE prompt_id = %s",
|
||||
(prompt_id,)
|
||||
)
|
||||
kb_count = cursor.fetchone()['count']
|
||||
|
||||
# 如果有引用,不允许删除
|
||||
if meeting_count > 0 or kb_count > 0:
|
||||
references = []
|
||||
if meeting_count > 0:
|
||||
references.append(f"{meeting_count}个会议")
|
||||
if kb_count > 0:
|
||||
references.append(f"{kb_count}个知识库")
|
||||
|
||||
return create_api_response(
|
||||
code="400",
|
||||
message=f"无法删除:该提示词被{' 和 '.join(references)}引用",
|
||||
data={
|
||||
"meeting_count": meeting_count,
|
||||
"kb_count": kb_count
|
||||
}
|
||||
)
|
||||
|
||||
# 删除提示词
|
||||
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
|
||||
connection.commit()
|
||||
return create_api_response(code="200", message="提示词删除成功")
|
||||
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
|
||||
connection.commit()
|
||||
return create_api_response(code="200", message="删除成功")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from typing import Optional
|
||||
from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo
|
||||
from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo, UserMcpInfo
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.response import create_api_response
|
||||
|
|
@ -13,6 +13,7 @@ import re
|
|||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -25,6 +26,59 @@ def validate_email(email: str) -> bool:
|
|||
def hash_password(password: str) -> str:
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
|
||||
def _generate_mcp_bot_id() -> str:
|
||||
return f"nexbot_{secrets.token_hex(8)}"
|
||||
|
||||
|
||||
def _generate_mcp_bot_secret() -> str:
|
||||
random_part = secrets.token_urlsafe(24).replace('-', '').replace('_', '')
|
||||
return f"nxbotsec_{random_part}"
|
||||
|
||||
|
||||
def _get_user_mcp_record(cursor, user_id: int):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at
|
||||
FROM sys_user_mcp
|
||||
WHERE user_id = %s
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
return cursor.fetchone()
|
||||
|
||||
|
||||
def _ensure_user_exists(cursor, user_id: int) -> bool:
|
||||
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def _serialize_user_mcp(record: dict) -> dict:
|
||||
return UserMcpInfo(**record).dict()
|
||||
|
||||
|
||||
def _ensure_user_mcp_record(connection, cursor, user_id: int):
|
||||
record = _get_user_mcp_record(cursor, user_id)
|
||||
if record:
|
||||
return record
|
||||
|
||||
bot_id = _generate_mcp_bot_id()
|
||||
while True:
|
||||
cursor.execute("SELECT id FROM sys_user_mcp WHERE bot_id = %s", (bot_id,))
|
||||
if not cursor.fetchone():
|
||||
break
|
||||
bot_id = _generate_mcp_bot_id()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sys_user_mcp (user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, 1, NULL, NOW(), NOW())
|
||||
""",
|
||||
(user_id, bot_id, _generate_mcp_bot_secret()),
|
||||
)
|
||||
connection.commit()
|
||||
return _get_user_mcp_record(cursor, user_id)
|
||||
|
||||
@router.get("/roles")
|
||||
def get_all_roles(current_user: dict = Depends(get_current_user)):
|
||||
"""获取所有角色列表"""
|
||||
|
|
@ -33,7 +87,7 @@ def get_all_roles(current_user: dict = Depends(get_current_user)):
|
|||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT role_id, role_name FROM roles ORDER BY role_id")
|
||||
cursor.execute("SELECT role_id, role_name FROM sys_roles ORDER BY role_id")
|
||||
roles = cursor.fetchall()
|
||||
return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles])
|
||||
|
||||
|
|
@ -42,20 +96,20 @@ def create_user(request: CreateUserRequest, current_user: dict = Depends(get_cur
|
|||
if current_user['role_id'] != 1: # 1 is admin
|
||||
return create_api_response(code="403", message="仅管理员有权限创建用户")
|
||||
|
||||
if not validate_email(request.email):
|
||||
if request.email and not validate_email(request.email):
|
||||
return create_api_response(code="400", message="邮箱格式不正确")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id FROM users WHERE username = %s", (request.username,))
|
||||
cursor.execute("SELECT user_id FROM sys_users WHERE username = %s", (request.username,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="用户名已存在")
|
||||
|
||||
password = request.password if request.password else SystemConfigService.get_default_reset_password()
|
||||
hashed_password = hash_password(password)
|
||||
|
||||
query = "INSERT INTO users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)"
|
||||
query = "INSERT INTO sys_users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)"
|
||||
created_at = datetime.datetime.utcnow()
|
||||
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
|
||||
connection.commit()
|
||||
|
|
@ -74,13 +128,13 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM users WHERE user_id = %s", (user_id,))
|
||||
cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM sys_users WHERE user_id = %s", (user_id,))
|
||||
existing_user = cursor.fetchone()
|
||||
if not existing_user:
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
if request.username and request.username != existing_user['username']:
|
||||
cursor.execute("SELECT user_id FROM users WHERE username = %s AND user_id != %s", (request.username, user_id))
|
||||
cursor.execute("SELECT user_id FROM sys_users WHERE username = %s AND user_id != %s", (request.username, user_id))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="用户名已存在")
|
||||
|
||||
|
|
@ -97,14 +151,14 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
|
|||
'role_id': target_role_id
|
||||
}
|
||||
|
||||
query = "UPDATE users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s"
|
||||
query = "UPDATE sys_users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s"
|
||||
cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['avatar_url'], update_data['role_id'], user_id))
|
||||
connection.commit()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
||||
FROM sys_users u
|
||||
LEFT JOIN sys_roles r ON u.role_id = r.role_id
|
||||
WHERE u.user_id = %s
|
||||
''', (user_id,))
|
||||
updated_user = cursor.fetchone()
|
||||
|
|
@ -117,9 +171,7 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
|
|||
avatar_url=updated_user['avatar_url'],
|
||||
created_at=updated_user['created_at'],
|
||||
role_id=updated_user['role_id'],
|
||||
role_name=updated_user['role_name'],
|
||||
meetings_created=0,
|
||||
meetings_attended=0
|
||||
role_name=updated_user['role_name'] or '普通用户'
|
||||
)
|
||||
return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict())
|
||||
|
||||
|
|
@ -131,11 +183,11 @@ def delete_user(user_id: int, current_user: dict = Depends(get_current_user)):
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,))
|
||||
cursor.execute("DELETE FROM sys_users WHERE user_id = %s", (user_id,))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message="用户删除成功")
|
||||
|
|
@ -148,13 +200,13 @@ def reset_password(user_id: int, current_user: dict = Depends(get_current_user))
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
hashed_password = hash_password(SystemConfigService.get_default_reset_password())
|
||||
|
||||
query = "UPDATE users SET password_hash = %s WHERE user_id = %s"
|
||||
query = "UPDATE sys_users SET password_hash = %s WHERE user_id = %s"
|
||||
cursor.execute(query, (hashed_password, user_id))
|
||||
connection.commit()
|
||||
|
||||
|
|
@ -185,7 +237,7 @@ def get_all_users(
|
|||
count_params.extend([search_pattern, search_pattern])
|
||||
|
||||
# 统计查询
|
||||
count_query = "SELECT COUNT(*) as total FROM users u"
|
||||
count_query = "SELECT COUNT(*) as total FROM sys_users u"
|
||||
if where_conditions:
|
||||
count_query += " WHERE " + " AND ".join(where_conditions)
|
||||
|
||||
|
|
@ -197,12 +249,16 @@ def get_all_users(
|
|||
# 主查询
|
||||
query = '''
|
||||
SELECT
|
||||
u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
|
||||
r.role_name,
|
||||
(SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created,
|
||||
(SELECT COUNT(*) FROM attendees WHERE user_id = u.user_id) as meetings_attended
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.caption,
|
||||
u.email,
|
||||
u.avatar_url,
|
||||
u.created_at,
|
||||
u.role_id,
|
||||
COALESCE(r.role_name, '普通用户') AS role_name
|
||||
FROM sys_users u
|
||||
LEFT JOIN sys_roles r ON u.role_id = r.role_id
|
||||
'''
|
||||
|
||||
query_params = []
|
||||
|
|
@ -231,9 +287,10 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
|
|||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
user_query = '''
|
||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
||||
FROM users u
|
||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
|
||||
COALESCE(r.role_name, '普通用户') AS role_name
|
||||
FROM sys_users u
|
||||
LEFT JOIN sys_roles r ON u.role_id = r.role_id
|
||||
WHERE u.user_id = %s
|
||||
'''
|
||||
cursor.execute(user_query, (user_id,))
|
||||
|
|
@ -242,14 +299,6 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
|
|||
if not user:
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
created_query = "SELECT COUNT(*) as count FROM meetings WHERE user_id = %s"
|
||||
cursor.execute(created_query, (user_id,))
|
||||
meetings_created = cursor.fetchone()['count']
|
||||
|
||||
attended_query = "SELECT COUNT(*) as count FROM attendees WHERE user_id = %s"
|
||||
cursor.execute(attended_query, (user_id,))
|
||||
meetings_attended = cursor.fetchone()['count']
|
||||
|
||||
user_info = UserInfo(
|
||||
user_id=user['user_id'],
|
||||
username=user['username'],
|
||||
|
|
@ -258,9 +307,7 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
|
|||
avatar_url=user['avatar_url'],
|
||||
created_at=user['created_at'],
|
||||
role_id=user['role_id'],
|
||||
role_name=user['role_name'],
|
||||
meetings_created=meetings_created,
|
||||
meetings_attended=meetings_attended
|
||||
role_name=user['role_name']
|
||||
)
|
||||
return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict())
|
||||
|
||||
|
|
@ -272,7 +319,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT password_hash FROM users WHERE user_id = %s", (user_id,))
|
||||
cursor.execute("SELECT password_hash FROM sys_users WHERE user_id = %s", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
|
|
@ -283,7 +330,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
|
|||
return create_api_response(code="400", message="旧密码错误")
|
||||
|
||||
new_password_hash = hash_password(request.new_password)
|
||||
cursor.execute("UPDATE users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id))
|
||||
cursor.execute("UPDATE sys_users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message="密码修改成功")
|
||||
|
|
@ -305,7 +352,7 @@ def upload_user_avatar(
|
|||
return create_api_response(code="400", message="不支持的文件类型")
|
||||
|
||||
# Ensure upload directory exists: AVATAR_DIR / str(user_id)
|
||||
user_avatar_dir = AVATAR_DIR / str(user_id)
|
||||
user_avatar_dir = config_module.get_user_avatar_dir(user_id)
|
||||
if not user_avatar_dir.exists():
|
||||
os.makedirs(user_avatar_dir)
|
||||
|
||||
|
|
@ -321,13 +368,57 @@ def upload_user_avatar(
|
|||
# AVATAR_DIR is uploads/user/avatar
|
||||
# file path is uploads/user/avatar/{user_id}/{filename}
|
||||
# URL should be /uploads/user/avatar/{user_id}/{filename}
|
||||
avatar_url = f"/uploads/user/avatar/{user_id}/{unique_filename}"
|
||||
avatar_url = f"/uploads/user/{user_id}/avatar/{unique_filename}"
|
||||
|
||||
# Update database
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("UPDATE users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id))
|
||||
cursor.execute("UPDATE sys_users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id))
|
||||
connection.commit()
|
||||
|
||||
return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url})
|
||||
return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url})
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/mcp-config")
|
||||
def get_user_mcp_config(user_id: int, current_user: dict = Depends(get_current_user)):
|
||||
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
|
||||
return create_api_response(code="403", message="没有权限查看该用户的MCP配置")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
if not _ensure_user_exists(cursor, user_id):
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
record = _ensure_user_mcp_record(connection, cursor, user_id)
|
||||
return create_api_response(code="200", message="获取MCP配置成功", data=_serialize_user_mcp(record))
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/mcp-config/regenerate")
|
||||
def regenerate_user_mcp_secret(user_id: int, current_user: dict = Depends(get_current_user)):
|
||||
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
|
||||
return create_api_response(code="403", message="没有权限更新该用户的MCP配置")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
if not _ensure_user_exists(cursor, user_id):
|
||||
return create_api_response(code="404", message="用户不存在")
|
||||
|
||||
record = _get_user_mcp_record(cursor, user_id)
|
||||
if not record:
|
||||
record = _ensure_user_mcp_record(connection, cursor, user_id)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE sys_user_mcp
|
||||
SET bot_secret = %s, status = 1, updated_at = NOW()
|
||||
WHERE user_id = %s
|
||||
""",
|
||||
(_generate_mcp_bot_secret(), user_id),
|
||||
)
|
||||
connection.commit()
|
||||
record = _get_user_mcp_record(cursor, user_id)
|
||||
|
||||
return create_api_response(code="200", message="MCP Secret 已重新生成", data=_serialize_user_mcp(record))
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@ def get_voiceprint_template(current_user: dict = Depends(get_current_user)):
|
|||
"""
|
||||
try:
|
||||
template_data = VoiceprintTemplate(
|
||||
template_text=SystemConfigService.get_voiceprint_template(),
|
||||
content=SystemConfigService.get_voiceprint_template(),
|
||||
duration_seconds=SystemConfigService.get_voiceprint_duration(),
|
||||
sample_rate=SystemConfigService.get_voiceprint_sample_rate(),
|
||||
channels=SystemConfigService.get_voiceprint_channels()
|
||||
)
|
||||
return create_api_response(code="200", message="获取朗读模板成功", data=template_data.dict())
|
||||
return create_api_response(code="200", message="获取朗读模板成功", data=template_data.model_dump())
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取朗读模板失败: {str(e)}")
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ async def upload_voiceprint(
|
|||
|
||||
try:
|
||||
# 确保用户目录存在
|
||||
user_voiceprint_dir = config_module.VOICEPRINT_DIR / str(user_id)
|
||||
user_voiceprint_dir = config_module.get_user_voiceprint_dir(user_id)
|
||||
user_voiceprint_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 生成文件名:时间戳.wav
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import contextlib
|
||||
|
||||
from app.api.endpoints import (
|
||||
admin,
|
||||
admin_dashboard,
|
||||
admin_settings,
|
||||
audio,
|
||||
auth,
|
||||
client_downloads,
|
||||
dict_data,
|
||||
external_apps,
|
||||
hot_words,
|
||||
knowledge_base,
|
||||
meetings,
|
||||
prompts,
|
||||
tags,
|
||||
tasks,
|
||||
terminals,
|
||||
users,
|
||||
voiceprint,
|
||||
)
|
||||
from app.core.config import UPLOAD_DIR
|
||||
from app.core.middleware import MCPPathNormalizeMiddleware, TerminalCheckMiddleware
|
||||
from app.mcp import create_mcp_http_app, get_mcp_session_manager
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="iMeeting API",
|
||||
description="iMeeting API说明",
|
||||
version="1.1.0",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.add_middleware(MCPPathNormalizeMiddleware)
|
||||
app.add_middleware(TerminalCheckMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if UPLOAD_DIR.exists():
|
||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
|
||||
|
||||
mcp_asgi_app = create_mcp_http_app()
|
||||
mcp_session_manager = get_mcp_session_manager()
|
||||
if mcp_asgi_app is not None:
|
||||
app.mount("/mcp", mcp_asgi_app, name="mcp")
|
||||
|
||||
app.include_router(auth.router, prefix="/api", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/api", tags=["Users"])
|
||||
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
|
||||
app.include_router(tags.router, prefix="/api", tags=["Tags"])
|
||||
app.include_router(admin.router, prefix="/api", tags=["Admin"])
|
||||
app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"])
|
||||
app.include_router(admin_settings.router, prefix="/api", tags=["AdminSettings"])
|
||||
app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
|
||||
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
|
||||
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
|
||||
app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"])
|
||||
app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"])
|
||||
app.include_router(dict_data.router, prefix="/api", tags=["DictData"])
|
||||
app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"])
|
||||
app.include_router(audio.router, prefix="/api", tags=["Audio"])
|
||||
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"])
|
||||
app.include_router(terminals.router, prefix="/api", tags=["Terminals"])
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def custom_swagger_ui_html():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=app.openapi_url,
|
||||
title=app.title + " - Swagger UI",
|
||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||
swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js",
|
||||
swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css",
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to iMeeting API"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "iMeeting API",
|
||||
"version": "1.1.0",
|
||||
}
|
||||
|
||||
if mcp_session_manager is not None:
|
||||
@app.on_event("startup")
|
||||
async def startup_mcp_session_manager():
|
||||
exit_stack = contextlib.AsyncExitStack()
|
||||
await exit_stack.enter_async_context(mcp_session_manager.run())
|
||||
app.state.mcp_exit_stack = exit_stack
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_mcp_session_manager():
|
||||
exit_stack = getattr(app.state, "mcp_exit_stack", None)
|
||||
if exit_stack is not None:
|
||||
await exit_stack.aclose()
|
||||
|
||||
SystemConfigService.ensure_builtin_parameters()
|
||||
|
||||
return app
|
||||
|
|
@ -24,7 +24,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
|
||||
"SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
|
|
@ -67,7 +67,7 @@ def get_optional_current_user(request: Request) -> Optional[dict]:
|
|||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT user_id, username, caption, email FROM users WHERE user_id = %s",
|
||||
"SELECT user_id, username, caption, email FROM sys_users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
return cursor.fetchone()
|
||||
|
|
|
|||
|
|
@ -3,12 +3,9 @@ import json
|
|||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载 .env 文件
|
||||
env_path = Path(__file__).parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
# 基础路径配置
|
||||
BASE_DIR = Path(__file__).parent.parent.parent
|
||||
REPO_DIR = BASE_DIR.parent
|
||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||
AUDIO_DIR = UPLOAD_DIR / "audio"
|
||||
TEMP_UPLOAD_DIR = UPLOAD_DIR / "temp_audio"
|
||||
|
|
@ -16,8 +13,63 @@ MARKDOWN_DIR = UPLOAD_DIR / "markdown"
|
|||
CLIENT_DIR = UPLOAD_DIR / "clients"
|
||||
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
|
||||
USER_DIR = UPLOAD_DIR / "user"
|
||||
VOICEPRINT_DIR = USER_DIR / "voiceprint"
|
||||
AVATAR_DIR = USER_DIR / "avatar"
|
||||
LEGACY_VOICEPRINT_DIR = USER_DIR / "voiceprint"
|
||||
LEGACY_AVATAR_DIR = USER_DIR / "avatar"
|
||||
VOICEPRINT_DIR = USER_DIR
|
||||
AVATAR_DIR = USER_DIR
|
||||
|
||||
|
||||
def _is_running_in_docker() -> bool:
|
||||
return Path("/.dockerenv").exists()
|
||||
|
||||
|
||||
def _load_env_file(dotenv_path: Path) -> None:
|
||||
if dotenv_path.exists():
|
||||
load_dotenv(dotenv_path=dotenv_path, override=False)
|
||||
|
||||
|
||||
# 非 Docker 本地运行时,兼容读取仓库根目录 .env 与 backend/.env;
|
||||
# Docker 容器内只使用显式注入的环境变量,避免意外读取镜像内的开发配置。
|
||||
if not _is_running_in_docker():
|
||||
_load_env_file(REPO_DIR / ".env")
|
||||
_load_env_file(BASE_DIR / ".env")
|
||||
|
||||
|
||||
def _get_env(*names: str, default: str | None = None, allow_blank: bool = True) -> str | None:
|
||||
for name in names:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
continue
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if value == "" and not allow_blank:
|
||||
continue
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _get_int_env(*names: str, default: int) -> int:
|
||||
raw_value = _get_env(*names, default=str(default), allow_blank=False)
|
||||
try:
|
||||
return int(raw_value) if raw_value is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_base_url(value: str | None) -> str:
|
||||
normalized = str(value or "").strip().rstrip("/")
|
||||
return normalized or "http://localhost"
|
||||
|
||||
|
||||
def get_user_data_dir(user_id: int | str) -> Path:
|
||||
return USER_DIR / str(user_id)
|
||||
|
||||
|
||||
def get_user_voiceprint_dir(user_id: int | str) -> Path:
|
||||
return get_user_data_dir(user_id) / "voiceprint"
|
||||
|
||||
|
||||
def get_user_avatar_dir(user_id: int | str) -> Path:
|
||||
return get_user_data_dir(user_id) / "avatar"
|
||||
|
||||
# 文件上传配置
|
||||
ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"}
|
||||
|
|
@ -35,24 +87,24 @@ MARKDOWN_DIR.mkdir(exist_ok=True)
|
|||
CLIENT_DIR.mkdir(exist_ok=True)
|
||||
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
|
||||
USER_DIR.mkdir(exist_ok=True)
|
||||
VOICEPRINT_DIR.mkdir(exist_ok=True)
|
||||
AVATAR_DIR.mkdir(exist_ok=True)
|
||||
LEGACY_VOICEPRINT_DIR.mkdir(exist_ok=True)
|
||||
LEGACY_AVATAR_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_CONFIG = {
|
||||
'host': os.getenv('DB_HOST', '127.0.0.1'),
|
||||
'user': os.getenv('DB_USER', 'root'),
|
||||
'password': os.getenv('DB_PASSWORD', ''),
|
||||
'database': os.getenv('DB_NAME', 'imeeting'),
|
||||
'port': int(os.getenv('DB_PORT', '3306')),
|
||||
'host': _get_env('DB_HOST', 'MYSQL_HOST', default='127.0.0.1', allow_blank=False),
|
||||
'user': _get_env('DB_USER', 'MYSQL_USER', default='root', allow_blank=False),
|
||||
'password': _get_env('DB_PASSWORD', 'MYSQL_PASSWORD', default=''),
|
||||
'database': _get_env('DB_NAME', 'MYSQL_DATABASE', default='imeeting', allow_blank=False),
|
||||
'port': _get_int_env('DB_PORT', 'MYSQL_PORT', default=3306),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
'host': os.getenv('API_HOST', '0.0.0.0'),
|
||||
'port': int(os.getenv('API_PORT', '8000'))
|
||||
'host': _get_env('API_HOST', default='0.0.0.0', allow_blank=False),
|
||||
'port': _get_int_env('API_PORT', default=8000)
|
||||
}
|
||||
|
||||
# 七牛云配置
|
||||
|
|
@ -63,25 +115,27 @@ API_CONFIG = {
|
|||
|
||||
# 应用配置
|
||||
APP_CONFIG = {
|
||||
'base_url': os.getenv('BASE_URL', 'http://imeeting.unisspace.com')
|
||||
'base_url': _normalize_base_url(_get_env('BASE_URL', default='http://localhost', allow_blank=False))
|
||||
}
|
||||
|
||||
# Redis配置
|
||||
REDIS_CONFIG = {
|
||||
'host': os.getenv('REDIS_HOST', '127.0.0.1'),
|
||||
'port': int(os.getenv('REDIS_PORT', '6379')),
|
||||
'db': int(os.getenv('REDIS_DB', '0')),
|
||||
'password': os.getenv('REDIS_PASSWORD', ''),
|
||||
'host': _get_env('REDIS_HOST', default='127.0.0.1', allow_blank=False),
|
||||
'port': _get_int_env('REDIS_PORT', default=6379),
|
||||
'db': _get_int_env('REDIS_DB', default=0),
|
||||
'password': _get_env('REDIS_PASSWORD', default=''),
|
||||
'decode_responses': True
|
||||
}
|
||||
|
||||
# Dashscope (Tongyi Qwen) API Key
|
||||
QWEN_API_KEY = os.getenv('QWEN_API_KEY', 'sk-c2bf06ea56b4491ea3d1e37fdb472b8f')
|
||||
|
||||
# 转录轮询配置 - 用于 upload-audio-complete 接口
|
||||
TRANSCRIPTION_POLL_CONFIG = {
|
||||
'poll_interval': int(os.getenv('TRANSCRIPTION_POLL_INTERVAL', '10')), # 轮询间隔:10秒
|
||||
'max_wait_time': int(os.getenv('TRANSCRIPTION_MAX_WAIT_TIME', '1800')), # 最大等待:30分钟
|
||||
'poll_interval': _get_int_env('TRANSCRIPTION_POLL_INTERVAL', default=10), # 轮询间隔:10秒
|
||||
'max_wait_time': _get_int_env('TRANSCRIPTION_MAX_WAIT_TIME', default=1800), # 最大等待:30分钟
|
||||
}
|
||||
|
||||
|
||||
# 后台任务配置
|
||||
BACKGROUND_TASK_CONFIG = {
|
||||
'summary_workers': _get_int_env('SUMMARY_TASK_MAX_WORKERS', default=2),
|
||||
'monitor_workers': _get_int_env('MONITOR_TASK_MAX_WORKERS', default=8),
|
||||
'transcription_status_cache_ttl': _get_int_env('TRANSCRIPTION_STATUS_CACHE_TTL', default=3),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ def get_db_connection():
|
|||
connection = None
|
||||
try:
|
||||
connection = mysql.connector.connect(**DATABASE_CONFIG)
|
||||
yield connection
|
||||
except Error as e:
|
||||
print(f"数据库连接错误: {e}")
|
||||
raise HTTPException(status_code=500, detail="数据库连接失败")
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
if connection and connection.is_connected():
|
||||
try:
|
||||
# 确保清理任何未读结果
|
||||
if connection.unread_result:
|
||||
connection.consume_results()
|
||||
connection.close()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,26 @@ from app.services.terminal_service import terminal_service
|
|||
from app.services.jwt_service import jwt_service
|
||||
from app.core.response import create_api_response
|
||||
|
||||
|
||||
class MCPPathNormalizeMiddleware:
|
||||
"""Normalize the public MCP endpoint to /mcp while keeping the mounted app happy."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http" and scope.get("path") == "/mcp":
|
||||
normalized_scope = dict(scope)
|
||||
normalized_scope["path"] = "/mcp/"
|
||||
|
||||
raw_path = scope.get("raw_path")
|
||||
if raw_path in (None, b"/mcp"):
|
||||
normalized_scope["raw_path"] = b"/mcp/"
|
||||
|
||||
scope = normalized_scope
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
class TerminalCheckMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行
|
||||
|
|
|
|||
|
|
@ -1,91 +1,32 @@
|
|||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
# 无论从哪里运行,都能正确找到 app 模块
|
||||
current_file = Path(__file__).resolve()
|
||||
project_root = current_file.parent.parent # backend/
|
||||
package_dir = current_file.parent
|
||||
project_root = current_file.parent.parent
|
||||
|
||||
# When this file is executed as `python app/main.py`, Python automatically puts
|
||||
# `/.../backend/app` on sys.path. That shadows the third-party `mcp` package
|
||||
# with our local `app/mcp` package and breaks `from mcp.server.fastmcp import FastMCP`.
|
||||
try:
|
||||
sys.path.remove(str(package_dir))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from app.core.middleware import TerminalCheckMiddleware
|
||||
from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps, terminals
|
||||
from app.core.config import UPLOAD_DIR, API_CONFIG
|
||||
|
||||
app = FastAPI(
|
||||
title="iMeeting API",
|
||||
description="iMeeting API说明",
|
||||
version="1.1.0",
|
||||
docs_url=None, # 禁用默认docs,使用自定义CDN
|
||||
redoc_url=None
|
||||
)
|
||||
from app.app_factory import create_app
|
||||
from app.core.config import API_CONFIG
|
||||
|
||||
# 添加终端检查中间件 (在CORS之前添加,以便位于CORS内部)
|
||||
app.add_middleware(TerminalCheckMiddleware)
|
||||
|
||||
# 添加CORS中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app = create_app()
|
||||
|
||||
# 静态文件服务 - 提供音频文件下载
|
||||
if UPLOAD_DIR.exists():
|
||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
|
||||
|
||||
# 包含API路由
|
||||
app.include_router(auth.router, prefix="/api", tags=["Authentication"])
|
||||
app.include_router(users.router, prefix="/api", tags=["Users"])
|
||||
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
|
||||
app.include_router(tags.router, prefix="/api", tags=["Tags"])
|
||||
app.include_router(admin.router, prefix="/api", tags=["Admin"])
|
||||
app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"])
|
||||
app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
|
||||
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
|
||||
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
|
||||
app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"])
|
||||
app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"])
|
||||
app.include_router(dict_data.router, prefix="/api", tags=["DictData"])
|
||||
app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"])
|
||||
app.include_router(audio.router, prefix="/api", tags=["Audio"])
|
||||
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"])
|
||||
app.include_router(terminals.router, prefix="/api", tags=["Terminals"])
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def custom_swagger_ui_html():
|
||||
"""自定义Swagger UI,使用国内可访问的CDN"""
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=app.openapi_url,
|
||||
title=app.title + " - Swagger UI",
|
||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||
swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js",
|
||||
swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css",
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to iMeeting API"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""健康检查端点"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "iMeeting API",
|
||||
"version": "1.1.0"
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 简单的uvicorn配置,避免参数冲突
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=API_CONFIG['host'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
from app.mcp.server import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
|
||||
|
||||
__all__ = ["create_mcp_http_app", "get_mcp_session_manager", "MCPHeaderAuthApp"]
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class MCPRequestContext:
|
||||
user: Dict[str, Any]
|
||||
bot_id: str
|
||||
credential_id: int
|
||||
authenticated_at: datetime
|
||||
|
||||
|
||||
current_mcp_request: ContextVar[Optional[MCPRequestContext]] = ContextVar(
|
||||
"current_mcp_request",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def set_current_mcp_request(request: MCPRequestContext) -> Token:
|
||||
return current_mcp_request.set(request)
|
||||
|
||||
|
||||
def reset_current_mcp_request(token: Token) -> None:
|
||||
current_mcp_request.reset(token)
|
||||
|
||||
|
||||
def require_mcp_request() -> MCPRequestContext:
|
||||
request = current_mcp_request.get()
|
||||
if request is None:
|
||||
raise RuntimeError("MCP request context is unavailable")
|
||||
return request
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
"""
|
||||
Backend-integrated MCP Streamable HTTP server.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError: # pragma: no cover - runtime dependency
|
||||
FastMCP = None
|
||||
|
||||
from app.core.config import APP_CONFIG, API_CONFIG
|
||||
from app.core.database import get_db_connection
|
||||
from app.mcp.context import (
|
||||
MCPRequestContext,
|
||||
require_mcp_request,
|
||||
reset_current_mcp_request,
|
||||
set_current_mcp_request,
|
||||
)
|
||||
from app.services import meeting_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_absolute_url(path: str) -> str:
|
||||
normalized_base = APP_CONFIG["base_url"].rstrip("/")
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{normalized_base}{normalized_path}"
|
||||
|
||||
|
||||
def _parse_api_response(response: JSONResponse) -> Dict[str, Any]:
|
||||
body = response.body.decode("utf-8") if isinstance(response.body, (bytes, bytearray)) else response.body
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def _load_user_meetings(current_user: Dict[str, Any], filter_type: str) -> list[Dict[str, Any]]:
|
||||
meetings: list[Dict[str, Any]] = []
|
||||
page = 1
|
||||
page_size = 100
|
||||
|
||||
while True:
|
||||
payload = _parse_api_response(
|
||||
meeting_service.get_meetings(
|
||||
current_user=current_user,
|
||||
user_id=current_user["user_id"],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
filter_type=filter_type,
|
||||
)
|
||||
)
|
||||
if payload.get("code") != "200":
|
||||
raise ValueError(payload.get("message") or "获取会议列表失败")
|
||||
|
||||
data = payload.get("data") or {}
|
||||
meetings.extend(data.get("meetings") or [])
|
||||
if not data.get("has_more"):
|
||||
break
|
||||
page += 1
|
||||
|
||||
return meetings
|
||||
|
||||
|
||||
def _find_user_meeting(current_user: Dict[str, Any], meeting_id: int) -> Dict[str, Any]:
|
||||
for filter_type in ("created", "attended"):
|
||||
for meeting in _load_user_meetings(current_user, filter_type):
|
||||
if int(meeting.get("meeting_id")) == int(meeting_id):
|
||||
return meeting
|
||||
|
||||
raise ValueError("会议不存在,或当前账号无权访问该会议")
|
||||
|
||||
|
||||
def _get_meeting_preview_payload(meeting_id: int, access_password: Optional[str]) -> Dict[str, Any]:
|
||||
return _parse_api_response(
|
||||
meeting_service.get_meeting_preview_data(
|
||||
meeting_id=meeting_id,
|
||||
password=access_password,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_mcp_user(bot_id: str, bot_secret: str) -> Optional[Dict[str, Any]]:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.bot_id,
|
||||
m.bot_secret,
|
||||
m.status,
|
||||
u.username,
|
||||
u.caption,
|
||||
u.email,
|
||||
u.role_id
|
||||
FROM sys_user_mcp m
|
||||
JOIN sys_users u ON m.user_id = u.user_id
|
||||
WHERE m.bot_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(bot_id,),
|
||||
)
|
||||
record = cursor.fetchone()
|
||||
if not record:
|
||||
return None
|
||||
|
||||
if int(record.get("status") or 0) != 1:
|
||||
return None
|
||||
|
||||
if not hmac.compare_digest(str(record.get("bot_secret") or ""), bot_secret):
|
||||
return None
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE sys_user_mcp SET last_used_at = NOW() WHERE id = %s",
|
||||
(record["id"],),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
return {
|
||||
"credential_id": record["id"],
|
||||
"bot_id": record["bot_id"],
|
||||
"user": {
|
||||
"user_id": record["user_id"],
|
||||
"username": record["username"],
|
||||
"caption": record["caption"],
|
||||
"email": record["email"],
|
||||
"role_id": record["role_id"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
class MCPHeaderAuthApp:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = Headers(scope=scope)
|
||||
bot_id = (headers.get("x-bot-id") or "").strip()
|
||||
bot_secret = (headers.get("x-bot-secret") or "").strip()
|
||||
|
||||
if not bot_id or not bot_secret:
|
||||
response = JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "Missing X-Bot-Id or X-Bot-Secret"},
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
auth_record = _get_mcp_user(bot_id, bot_secret)
|
||||
if not auth_record:
|
||||
response = JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "Invalid MCP credentials"},
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
token = set_current_mcp_request(
|
||||
MCPRequestContext(
|
||||
user=auth_record["user"],
|
||||
bot_id=auth_record["bot_id"],
|
||||
credential_id=auth_record["credential_id"],
|
||||
authenticated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
reset_current_mcp_request(token)
|
||||
|
||||
|
||||
_mcp_http_app = None
|
||||
|
||||
|
||||
if FastMCP is not None:
|
||||
mcp_server = FastMCP(
|
||||
"iMeeting MCP",
|
||||
host=API_CONFIG["host"],
|
||||
port=API_CONFIG["port"],
|
||||
json_response=True,
|
||||
stateless_http=True,
|
||||
streamable_http_path="/",
|
||||
)
|
||||
|
||||
@mcp_server.tool()
|
||||
def get_my_meetings() -> Dict[str, Any]:
|
||||
"""获取当前登录用户的会议列表,并按我创建/我参加分开返回。"""
|
||||
current_user = require_mcp_request().user
|
||||
created_meetings = _load_user_meetings(current_user, "created")
|
||||
attended_meetings = _load_user_meetings(current_user, "attended")
|
||||
|
||||
return {
|
||||
"user": {
|
||||
"user_id": current_user["user_id"],
|
||||
"username": current_user.get("username"),
|
||||
"caption": current_user.get("caption"),
|
||||
},
|
||||
"created_meetings": created_meetings,
|
||||
"attended_meetings": attended_meetings,
|
||||
"counts": {
|
||||
"created": len(created_meetings),
|
||||
"attended": len(attended_meetings),
|
||||
"all": len(created_meetings) + len(attended_meetings),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@mcp_server.tool()
|
||||
def get_meeting_preview_url(meeting_id: int) -> Dict[str, Any]:
|
||||
"""获取指定会议的预览地址。"""
|
||||
current_user = require_mcp_request().user
|
||||
meeting = _find_user_meeting(current_user, meeting_id)
|
||||
preview_payload = _get_meeting_preview_payload(meeting_id, meeting.get("access_password"))
|
||||
|
||||
if preview_payload.get("code") != "200":
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"title": meeting.get("title"),
|
||||
"message": preview_payload.get("message"),
|
||||
"status": (preview_payload.get("data") or {}).get("processing_status"),
|
||||
}
|
||||
|
||||
return {
|
||||
"meeting_id": meeting_id,
|
||||
"title": meeting.get("title"),
|
||||
"preview_url": _build_absolute_url(f"/meetings/preview/{meeting_id}"),
|
||||
"requires_password": bool(meeting.get("access_password")),
|
||||
"access_password": meeting.get("access_password"),
|
||||
"preview_data": preview_payload.get("data"),
|
||||
}
|
||||
else: # pragma: no cover - graceful fallback without runtime dependency
|
||||
mcp_server = None
|
||||
logger.warning("FastMCP is unavailable because the 'mcp' package is not installed; /mcp will not be mounted.")
|
||||
|
||||
|
||||
def get_mcp_server():
|
||||
return mcp_server
|
||||
|
||||
|
||||
def get_mcp_session_manager():
|
||||
if create_mcp_http_app() is None:
|
||||
return None
|
||||
return mcp_server.session_manager
|
||||
|
||||
|
||||
def create_mcp_http_app():
|
||||
global _mcp_http_app
|
||||
|
||||
if mcp_server is None:
|
||||
return None
|
||||
if _mcp_http_app is None:
|
||||
# FastMCP initializes its session manager when the Streamable HTTP app
|
||||
# is created, so cache the app and reuse it across startup/mounting.
|
||||
_mcp_http_app = MCPHeaderAuthApp(mcp_server.streamable_http_app())
|
||||
return _mcp_http_app
|
||||
|
||||
|
||||
def get_mcp_asgi_app():
|
||||
return create_mcp_http_app()
|
||||
|
|
@ -1,20 +1,12 @@
|
|||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, Union, List
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
from typing import List, Optional, Any, Dict, Union
|
||||
import datetime
|
||||
|
||||
# 认证相关模型
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
caption: str
|
||||
email: EmailStr
|
||||
avatar_url: Optional[str] = None
|
||||
token: str
|
||||
role_id: int
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
role_id: int
|
||||
role_name: str
|
||||
|
|
@ -23,102 +15,111 @@ class UserInfo(BaseModel):
|
|||
user_id: int
|
||||
username: str
|
||||
caption: str
|
||||
email: EmailStr
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
meetings_created: int
|
||||
meetings_attended: int
|
||||
email: Optional[str] = None
|
||||
role_id: int
|
||||
role_name: str
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
user: UserInfo
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserInfo]
|
||||
users: List[UserInfo]
|
||||
total: int
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: Optional[str] = None
|
||||
caption: str
|
||||
email: EmailStr
|
||||
email: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
role_id: int
|
||||
role_id: int = 2
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
caption: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
role_id: Optional[int] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
class UserLog(BaseModel):
|
||||
log_id: int
|
||||
user_id: int
|
||||
action_type: str
|
||||
username: str
|
||||
action: str
|
||||
details: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
created_at: datetime.datetime
|
||||
|
||||
# 会议相关模型
|
||||
class AttendeeInfo(BaseModel):
|
||||
user_id: int
|
||||
user_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
caption: str
|
||||
|
||||
class Tag(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
class TranscriptionTaskStatus(BaseModel):
|
||||
task_id: str
|
||||
status: str # 'pending', 'processing', 'completed', 'failed'
|
||||
progress: int # 0-100
|
||||
meeting_id: int
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
status: str
|
||||
progress: int
|
||||
message: Optional[str] = None
|
||||
|
||||
class Meeting(BaseModel):
|
||||
meeting_id: int
|
||||
title: str
|
||||
meeting_time: Optional[datetime.datetime]
|
||||
summary: Optional[str]
|
||||
created_at: datetime.datetime
|
||||
attendees: Union[List[str], List[AttendeeInfo]] # Support both formats
|
||||
meeting_time: datetime.datetime
|
||||
description: Optional[str] = None
|
||||
creator_id: int
|
||||
creator_username: str
|
||||
creator_account: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
attendees: List[AttendeeInfo]
|
||||
attendee_ids: Optional[List[int]] = None
|
||||
tags: List[Tag]
|
||||
audio_file_path: Optional[str] = None
|
||||
audio_duration: Optional[float] = None
|
||||
prompt_name: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
transcription_status: Optional[TranscriptionTaskStatus] = None
|
||||
tags: Optional[List[Tag]] = []
|
||||
llm_status: Optional[TranscriptionTaskStatus] = None
|
||||
prompt_id: Optional[int] = None
|
||||
prompt_name: Optional[str] = None
|
||||
overall_status: Optional[str] = None
|
||||
overall_progress: Optional[int] = None
|
||||
current_stage: Optional[str] = None
|
||||
access_password: Optional[str] = None
|
||||
|
||||
class TranscriptSegment(BaseModel):
|
||||
segment_id: int
|
||||
meeting_id: int
|
||||
speaker_id: Optional[int] = None # AI解析的原始结果
|
||||
speaker_id: int
|
||||
speaker_tag: str
|
||||
start_time_ms: int
|
||||
end_time_ms: int
|
||||
text_content: str
|
||||
|
||||
class CreateMeetingRequest(BaseModel):
|
||||
user_id: int
|
||||
title: str
|
||||
meeting_time: Optional[datetime.datetime]
|
||||
attendee_ids: list[int]
|
||||
tags: Optional[str] = None
|
||||
meeting_time: datetime.datetime
|
||||
attendee_ids: List[int] = Field(default_factory=list)
|
||||
description: Optional[str] = None
|
||||
tags: Optional[str] = None # 逗号分隔
|
||||
prompt_id: Optional[int] = None
|
||||
|
||||
class UpdateMeetingRequest(BaseModel):
|
||||
title: str
|
||||
meeting_time: Optional[datetime.datetime]
|
||||
summary: Optional[str]
|
||||
attendee_ids: list[int]
|
||||
title: Optional[str] = None
|
||||
meeting_time: Optional[datetime.datetime] = None
|
||||
attendee_ids: Optional[List[int]] = None
|
||||
description: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
prompt_id: Optional[int] = None
|
||||
|
||||
class SpeakerTagUpdateRequest(BaseModel):
|
||||
speaker_id: int # 使用原始speaker_id(整数)
|
||||
speaker_id: int
|
||||
new_tag: str
|
||||
|
||||
class BatchSpeakerTagUpdateRequest(BaseModel):
|
||||
|
|
@ -135,45 +136,66 @@ class PasswordChangeRequest(BaseModel):
|
|||
old_password: str
|
||||
new_password: str
|
||||
|
||||
# 提示词模版模型
|
||||
class PromptBase(BaseModel):
|
||||
name: str
|
||||
task_type: str # MEETING_TASK, KNOWLEDGE_TASK
|
||||
content: str
|
||||
desc: Optional[str] = None
|
||||
is_system: bool = False
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
class PromptCreate(PromptBase):
|
||||
pass
|
||||
|
||||
class PromptUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
task_type: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
desc: Optional[str] = None
|
||||
is_system: Optional[bool] = None
|
||||
is_default: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class PromptInfo(PromptBase):
|
||||
id: int
|
||||
creator_id: Optional[int] = None
|
||||
created_at: datetime.datetime
|
||||
|
||||
# 知识库相关模型
|
||||
class KnowledgeBase(BaseModel):
|
||||
kb_id: int
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
content: str
|
||||
creator_id: int
|
||||
creator_caption: str # To show in the UI
|
||||
created_by_name: str
|
||||
is_shared: bool
|
||||
source_meeting_ids: Optional[str] = None
|
||||
user_prompt: Optional[str] = None
|
||||
tags: Union[Optional[str], Optional[List[Tag]]] = None # 支持字符串或Tag列表
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
source_meeting_count: Optional[int] = 0
|
||||
created_by_name: Optional[str] = None
|
||||
source_meeting_count: int
|
||||
source_meetings: Optional[List[Meeting]] = None
|
||||
user_prompt: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
prompt_id: Optional[int] = None
|
||||
|
||||
class KnowledgeBaseTask(BaseModel):
|
||||
task_id: str
|
||||
user_id: int
|
||||
kb_id: int
|
||||
user_prompt: Optional[str] = None
|
||||
status: str
|
||||
progress: int
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
completed_at: Optional[datetime.datetime] = None
|
||||
message: Optional[str] = None
|
||||
result: Optional[str] = None
|
||||
|
||||
class CreateKnowledgeBaseRequest(BaseModel):
|
||||
title: Optional[str] = None # 改为可选,后台自动生成
|
||||
is_shared: bool
|
||||
user_prompt: Optional[str] = None
|
||||
source_meeting_ids: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版
|
||||
source_meeting_ids: str # 逗号分隔
|
||||
is_shared: bool = False
|
||||
prompt_id: Optional[int] = None
|
||||
|
||||
class UpdateKnowledgeBaseRequest(BaseModel):
|
||||
title: str
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
is_shared: Optional[bool] = None
|
||||
|
||||
class KnowledgeBaseListResponse(BaseModel):
|
||||
kbs: List[KnowledgeBase]
|
||||
|
|
@ -182,70 +204,62 @@ class KnowledgeBaseListResponse(BaseModel):
|
|||
# 客户端下载相关模型
|
||||
class ClientDownload(BaseModel):
|
||||
id: int
|
||||
platform_type: Optional[str] = None # 兼容旧版:'mobile', 'desktop', 'terminal'
|
||||
platform_name: Optional[str] = None # 兼容旧版:'ios', 'android', 'windows', 'mac_intel', 'mac_m', 'linux'
|
||||
platform_code: str # 新版平台编码,关联 dict_data.dict_code
|
||||
platform_code: str
|
||||
platform_type: str # mobile, desktop, terminal
|
||||
platform_name: str
|
||||
version: str
|
||||
version_code: int
|
||||
download_url: str
|
||||
file_size: Optional[int] = None
|
||||
release_notes: Optional[str] = None
|
||||
min_system_version: Optional[str] = None
|
||||
is_active: bool
|
||||
is_latest: bool
|
||||
min_system_version: Optional[str] = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
created_by: Optional[int] = None
|
||||
|
||||
class CreateClientDownloadRequest(BaseModel):
|
||||
platform_type: Optional[str] = None # 兼容旧版
|
||||
platform_name: Optional[str] = None # 兼容旧版
|
||||
platform_code: str # 必填,关联 dict_data
|
||||
platform_code: str
|
||||
platform_type: Optional[str] = None
|
||||
platform_name: Optional[str] = None
|
||||
version: str
|
||||
version_code: int
|
||||
download_url: str
|
||||
file_size: Optional[int] = None
|
||||
release_notes: Optional[str] = None
|
||||
min_system_version: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_latest: bool = False
|
||||
min_system_version: Optional[str] = None
|
||||
|
||||
class UpdateClientDownloadRequest(BaseModel):
|
||||
platform_code: Optional[str] = None
|
||||
platform_type: Optional[str] = None
|
||||
platform_name: Optional[str] = None
|
||||
platform_code: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
version_code: Optional[int] = None
|
||||
download_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
release_notes: Optional[str] = None
|
||||
min_system_version: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_latest: Optional[bool] = None
|
||||
min_system_version: Optional[str] = None
|
||||
|
||||
class ClientDownloadListResponse(BaseModel):
|
||||
clients: List[ClientDownload]
|
||||
total: int
|
||||
|
||||
# 声纹采集相关模型
|
||||
# 声纹相关模型
|
||||
class VoiceprintInfo(BaseModel):
|
||||
vp_id: int
|
||||
user_id: int
|
||||
file_path: str
|
||||
file_size: Optional[int] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
collected_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
voiceprint_data: Any
|
||||
created_at: datetime.datetime
|
||||
|
||||
class VoiceprintStatus(BaseModel):
|
||||
has_voiceprint: bool
|
||||
vp_id: Optional[int] = None
|
||||
file_path: Optional[str] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
collected_at: Optional[datetime.datetime] = None
|
||||
updated_at: Optional[datetime.datetime] = None
|
||||
|
||||
class VoiceprintTemplate(BaseModel):
|
||||
template_text: str
|
||||
content: str
|
||||
duration_seconds: int
|
||||
sample_rate: int
|
||||
channels: int
|
||||
|
|
@ -277,13 +291,51 @@ class RolePermissionInfo(BaseModel):
|
|||
class UpdateRolePermissionsRequest(BaseModel):
|
||||
menu_ids: List[int]
|
||||
|
||||
class CreateRoleRequest(BaseModel):
|
||||
role_name: str
|
||||
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
role_name: str
|
||||
|
||||
class CreateMenuRequest(BaseModel):
|
||||
menu_code: str
|
||||
menu_name: str
|
||||
menu_icon: Optional[str] = None
|
||||
menu_url: Optional[str] = None
|
||||
menu_type: str = "link"
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
description: Optional[str] = None
|
||||
|
||||
class UpdateMenuRequest(BaseModel):
|
||||
menu_code: Optional[str] = None
|
||||
menu_name: Optional[str] = None
|
||||
menu_icon: Optional[str] = None
|
||||
menu_url: Optional[str] = None
|
||||
menu_type: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
class UserMcpInfo(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
bot_id: str
|
||||
bot_secret: str
|
||||
status: int
|
||||
last_used_at: Optional[datetime.datetime] = None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
|
||||
# 专用终端设备模型
|
||||
class Terminal(BaseModel):
|
||||
id: int
|
||||
imei: str
|
||||
terminal_name: Optional[str] = None
|
||||
terminal_type: str
|
||||
terminal_type_name: Optional[str] = None # 终端类型名称(从字典获取)
|
||||
terminal_type_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: int # 1: 启用, 0: 停用
|
||||
is_activated: int # 1: 已激活, 0: 未激活
|
||||
|
|
@ -296,18 +348,23 @@ class Terminal(BaseModel):
|
|||
updated_at: datetime.datetime
|
||||
created_by: Optional[int] = None
|
||||
creator_username: Optional[str] = None
|
||||
current_user_id: Optional[int] = None
|
||||
current_username: Optional[str] = None
|
||||
current_user_caption: Optional[str] = None
|
||||
|
||||
class CreateTerminalRequest(BaseModel):
|
||||
imei: str
|
||||
terminal_name: Optional[str] = None
|
||||
terminal_type: str
|
||||
description: Optional[str] = None
|
||||
firmware_version: Optional[str] = None
|
||||
mac_address: Optional[str] = None
|
||||
status: int = 1
|
||||
|
||||
class UpdateTerminalRequest(BaseModel):
|
||||
terminal_name: Optional[str] = None
|
||||
terminal_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
firmware_version: Optional[str] = None
|
||||
mac_address: Optional[str] = None
|
||||
status: Optional[int] = None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,597 @@
|
|||
from app.core.response import create_api_response
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.jwt_service import jwt_service
|
||||
from app.core.config import AUDIO_DIR, REDIS_CONFIG
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.async_meeting_service import async_meeting_service
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
import os
|
||||
import redis
|
||||
|
||||
|
||||
# Redis 客户端
|
||||
redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
# 常量定义
|
||||
AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.mpeg', '.mp4', '.webm')
|
||||
BYTES_TO_GB = 1024 ** 3
|
||||
|
||||
transcription_service = AsyncTranscriptionService()
|
||||
|
||||
|
||||
def _build_status_condition(status: str) -> str:
|
||||
"""构建任务状态查询条件"""
|
||||
if status == 'running':
|
||||
return "AND (t.status = 'pending' OR t.status = 'processing')"
|
||||
elif status == 'completed':
|
||||
return "AND t.status = 'completed'"
|
||||
elif status == 'failed':
|
||||
return "AND t.status = 'failed'"
|
||||
return ""
|
||||
|
||||
|
||||
def _get_task_stats_query() -> str:
|
||||
"""获取任务统计的 SQL 查询"""
|
||||
return """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
|
||||
"""
|
||||
|
||||
|
||||
def _get_online_user_count(redis_client) -> int:
|
||||
"""从 Redis 获取在线用户数"""
|
||||
try:
|
||||
token_keys = redis_client.keys("token:*")
|
||||
user_ids = set()
|
||||
for key in token_keys:
|
||||
if isinstance(key, bytes):
|
||||
key = key.decode("utf-8", errors="ignore")
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 2:
|
||||
user_ids.add(parts[1])
|
||||
return len(user_ids)
|
||||
except Exception as e:
|
||||
print(f"获取在线用户数失败: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def _table_exists(cursor, table_name: str) -> bool:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = %s
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
return (cursor.fetchone() or {}).get("cnt", 0) > 0
|
||||
|
||||
|
||||
def _calculate_audio_storage() -> Dict[str, float]:
|
||||
"""计算音频文件存储统计"""
|
||||
audio_files_count = 0
|
||||
audio_total_size = 0
|
||||
|
||||
try:
|
||||
if os.path.exists(AUDIO_DIR):
|
||||
for root, _, files in os.walk(AUDIO_DIR):
|
||||
for file in files:
|
||||
file_extension = os.path.splitext(file)[1].lower()
|
||||
if file_extension in AUDIO_FILE_EXTENSIONS:
|
||||
audio_files_count += 1
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
audio_total_size += os.path.getsize(file_path)
|
||||
except OSError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"统计音频文件失败: {e}")
|
||||
|
||||
return {
|
||||
"audio_file_count": audio_files_count,
|
||||
"audio_files_count": audio_files_count,
|
||||
"audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2)
|
||||
}
|
||||
|
||||
|
||||
async def get_dashboard_stats(current_user=None):
|
||||
"""获取管理员 Dashboard 统计数据"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 1. 用户统计
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
total_users = 0
|
||||
today_new_users = 0
|
||||
|
||||
if _table_exists(cursor, "sys_users"):
|
||||
cursor.execute("SELECT COUNT(*) as total FROM sys_users")
|
||||
total_users = (cursor.fetchone() or {}).get("total", 0)
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM sys_users WHERE created_at >= %s",
|
||||
(today_start,),
|
||||
)
|
||||
today_new_users = (cursor.fetchone() or {}).get("count", 0)
|
||||
|
||||
online_users = _get_online_user_count(redis_client)
|
||||
|
||||
# 2. 会议统计
|
||||
total_meetings = 0
|
||||
today_new_meetings = 0
|
||||
if _table_exists(cursor, "meetings"):
|
||||
cursor.execute("SELECT COUNT(*) as total FROM meetings")
|
||||
total_meetings = (cursor.fetchone() or {}).get("total", 0)
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
|
||||
(today_start,),
|
||||
)
|
||||
today_new_meetings = (cursor.fetchone() or {}).get("count", 0)
|
||||
|
||||
# 3. 任务统计
|
||||
task_stats_query = _get_task_stats_query()
|
||||
|
||||
# 转录任务
|
||||
if _table_exists(cursor, "transcript_tasks"):
|
||||
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
|
||||
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
else:
|
||||
transcription_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 总结任务
|
||||
if _table_exists(cursor, "llm_tasks"):
|
||||
cursor.execute(f"{task_stats_query} FROM llm_tasks")
|
||||
summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
else:
|
||||
summary_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 知识库任务
|
||||
if _table_exists(cursor, "knowledge_base_tasks"):
|
||||
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
|
||||
kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
else:
|
||||
kb_stats = {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
|
||||
|
||||
# 4. 音频存储统计
|
||||
storage_stats = _calculate_audio_storage()
|
||||
|
||||
# 组装返回数据
|
||||
stats = {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"today_new": today_new_users,
|
||||
"online": online_users
|
||||
},
|
||||
"meetings": {
|
||||
"total": total_meetings,
|
||||
"today_new": today_new_meetings
|
||||
},
|
||||
"tasks": {
|
||||
"transcription": {
|
||||
"total": transcription_stats['total'] or 0,
|
||||
"running": transcription_stats['running'] or 0,
|
||||
"completed": transcription_stats['completed'] or 0,
|
||||
"failed": transcription_stats['failed'] or 0
|
||||
},
|
||||
"summary": {
|
||||
"total": summary_stats['total'] or 0,
|
||||
"running": summary_stats['running'] or 0,
|
||||
"completed": summary_stats['completed'] or 0,
|
||||
"failed": summary_stats['failed'] or 0
|
||||
},
|
||||
"knowledge_base": {
|
||||
"total": kb_stats['total'] or 0,
|
||||
"running": kb_stats['running'] or 0,
|
||||
"completed": kb_stats['completed'] or 0,
|
||||
"failed": kb_stats['failed'] or 0
|
||||
}
|
||||
},
|
||||
"storage": storage_stats
|
||||
}
|
||||
|
||||
return create_api_response(code="200", message="获取统计数据成功", data=stats)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取Dashboard统计数据失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}")
|
||||
|
||||
|
||||
async def get_online_users(current_user=None):
|
||||
"""获取在线用户列表"""
|
||||
try:
|
||||
token_keys = redis_client.keys("token:*")
|
||||
|
||||
# 提取用户ID并去重
|
||||
user_tokens = {}
|
||||
for key in token_keys:
|
||||
if isinstance(key, bytes):
|
||||
key = key.decode("utf-8", errors="ignore")
|
||||
parts = key.split(':')
|
||||
if len(parts) >= 3:
|
||||
user_id = int(parts[1])
|
||||
token = parts[2]
|
||||
if user_id not in user_tokens:
|
||||
user_tokens[user_id] = []
|
||||
user_tokens[user_id].append({'token': token, 'key': key})
|
||||
|
||||
# 查询用户信息
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
online_users_list = []
|
||||
for user_id, tokens in user_tokens.items():
|
||||
cursor.execute(
|
||||
"SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s",
|
||||
(user_id,)
|
||||
)
|
||||
user = cursor.fetchone()
|
||||
if user:
|
||||
ttl_seconds = redis_client.ttl(tokens[0]['key'])
|
||||
online_users_list.append({
|
||||
**user,
|
||||
'token_count': len(tokens),
|
||||
'ttl_seconds': ttl_seconds,
|
||||
'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0
|
||||
})
|
||||
|
||||
# 按用户ID排序
|
||||
online_users_list.sort(key=lambda x: x['user_id'])
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取在线用户列表成功",
|
||||
data={"users": online_users_list, "total": len(online_users_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取在线用户列表失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}")
|
||||
|
||||
|
||||
async def kick_user(user_id: int, current_user=None):
|
||||
"""踢出用户(撤销该用户的所有 token)"""
|
||||
try:
|
||||
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
|
||||
|
||||
if revoked_count > 0:
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message=f"已踢出用户,撤销了 {revoked_count} 个 token",
|
||||
data={"user_id": user_id, "revoked_count": revoked_count}
|
||||
)
|
||||
else:
|
||||
return create_api_response(
|
||||
code="404",
|
||||
message="该用户当前不在线或未找到 token"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"踢出用户失败: {e}")
|
||||
return create_api_response(code="500", message=f"踢出用户失败: {str(e)}")
|
||||
|
||||
|
||||
async def monitor_tasks(
|
||||
task_type: str = 'all',
|
||||
status: str = 'all',
|
||||
limit: int = 20,
|
||||
current_user=None
|
||||
):
|
||||
"""监控任务进度"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
tasks = []
|
||||
status_condition = _build_status_condition(status)
|
||||
|
||||
# 转录任务
|
||||
if task_type in ['all', 'transcription']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'transcription' as task_type,
|
||||
t.meeting_id,
|
||||
m.title as meeting_title,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.completed_at,
|
||||
u.username as creator_name
|
||||
FROM transcript_tasks t
|
||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN sys_users u ON m.user_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 总结任务
|
||||
if task_type in ['all', 'summary']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'summary' as task_type,
|
||||
t.meeting_id,
|
||||
m.title as meeting_title,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.result,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.completed_at,
|
||||
u.username as creator_name
|
||||
FROM llm_tasks t
|
||||
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
|
||||
LEFT JOIN sys_users u ON m.user_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 知识库任务
|
||||
if task_type in ['all', 'knowledge_base']:
|
||||
query = f"""
|
||||
SELECT
|
||||
t.task_id,
|
||||
'knowledge_base' as task_type,
|
||||
t.kb_id as meeting_id,
|
||||
k.title as meeting_title,
|
||||
t.status,
|
||||
t.progress,
|
||||
t.error_message,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
u.username as creator_name
|
||||
FROM knowledge_base_tasks t
|
||||
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
|
||||
LEFT JOIN sys_users u ON k.creator_id = u.user_id
|
||||
WHERE 1=1 {status_condition}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
cursor.execute(query, (limit,))
|
||||
tasks.extend(cursor.fetchall())
|
||||
|
||||
# 按创建时间排序并限制返回数量
|
||||
tasks.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
tasks = tasks[:limit]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取任务监控数据成功",
|
||||
data={"tasks": tasks, "total": len(tasks)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取任务监控数据失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}")
|
||||
|
||||
|
||||
def _parse_optional_int(value):
|
||||
if value in (None, "", "None"):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def retry_task(task_type: str, task_id: str, current_user=None):
|
||||
"""重试或恢复后台任务。"""
|
||||
try:
|
||||
normalized_type = (task_type or "").strip().lower()
|
||||
if normalized_type == "summary":
|
||||
return _retry_summary_task(task_id)
|
||||
if normalized_type == "transcription":
|
||||
return _retry_transcription_task(task_id)
|
||||
return create_api_response(code="400", message="不支持的任务类型")
|
||||
except Exception as e:
|
||||
print(f"重试任务失败: {e}")
|
||||
return create_api_response(code="500", message=f"重试任务失败: {str(e)}")
|
||||
|
||||
|
||||
def _retry_summary_task(task_id: str):
|
||||
task_data = async_meeting_service._get_task_from_db(task_id)
|
||||
if not task_data:
|
||||
return create_api_response(code="404", message="总结任务不存在")
|
||||
|
||||
status = str(task_data.get("status") or "").lower()
|
||||
meeting_id = _parse_optional_int(task_data.get("meeting_id"))
|
||||
if not meeting_id:
|
||||
return create_api_response(code="400", message="总结任务缺少关联会议")
|
||||
|
||||
if status in {"pending", "processing"}:
|
||||
async_meeting_service._resume_task_if_needed(task_id, task_data)
|
||||
status_info = async_meeting_service.get_task_status(task_id)
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="总结任务已尝试恢复",
|
||||
data={"task_id": task_id, "status": status_info.get("status"), "progress": status_info.get("progress", 0)},
|
||||
)
|
||||
|
||||
if status == "completed":
|
||||
return create_api_response(code="400", message="总结任务已完成,无需重试")
|
||||
|
||||
prompt_id = _parse_optional_int(task_data.get("prompt_id"))
|
||||
user_prompt = "" if task_data.get("user_prompt") in (None, "None") else str(task_data.get("user_prompt"))
|
||||
model_code = "" if task_data.get("model_code") in (None, "None") else str(task_data.get("model_code"))
|
||||
if not model_code:
|
||||
redis_task_data = async_meeting_service.redis_client.hgetall(f"llm_task:{task_id}") or {}
|
||||
model_code = redis_task_data.get("model_code") or ""
|
||||
new_task_id, _ = async_meeting_service.enqueue_summary_generation(
|
||||
meeting_id,
|
||||
user_prompt=user_prompt,
|
||||
prompt_id=prompt_id,
|
||||
model_code=model_code or None,
|
||||
)
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="总结任务已重新提交",
|
||||
data={"task_id": new_task_id, "previous_task_id": task_id, "status": "pending", "meeting_id": meeting_id},
|
||||
)
|
||||
|
||||
|
||||
def _retry_transcription_task(task_id: str):
|
||||
task_data = transcription_service._get_task_from_db(task_id)
|
||||
if not task_data:
|
||||
return create_api_response(code="404", message="转录任务不存在")
|
||||
|
||||
status = str(task_data.get("status") or "").lower()
|
||||
meeting_id = _parse_optional_int(task_data.get("meeting_id"))
|
||||
if not meeting_id:
|
||||
return create_api_response(code="400", message="转录任务缺少关联会议")
|
||||
|
||||
if status in {"pending", "processing"}:
|
||||
status_info = transcription_service.get_task_status(task_id)
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="转录任务状态已刷新",
|
||||
data={"task_id": task_id, "status": status_info.get("status"), "progress": status_info.get("progress", 0)},
|
||||
)
|
||||
|
||||
if status == "completed":
|
||||
return create_api_response(code="400", message="转录任务已完成,无需重试")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,))
|
||||
audio_file = cursor.fetchone()
|
||||
cursor.execute("SELECT prompt_id FROM meetings WHERE meeting_id = %s LIMIT 1", (meeting_id,))
|
||||
meeting = cursor.fetchone()
|
||||
cursor.close()
|
||||
|
||||
if not audio_file or not audio_file.get("file_path"):
|
||||
return create_api_response(code="400", message="会议缺少可用音频,无法重试转录")
|
||||
|
||||
new_task_id = transcription_service.start_transcription(meeting_id, audio_file["file_path"])
|
||||
async_meeting_service.enqueue_transcription_monitor(
|
||||
meeting_id,
|
||||
new_task_id,
|
||||
_parse_optional_int((meeting or {}).get("prompt_id")),
|
||||
None,
|
||||
)
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="转录任务已重新提交",
|
||||
data={"task_id": new_task_id, "previous_task_id": task_id, "status": "pending", "meeting_id": meeting_id},
|
||||
)
|
||||
|
||||
|
||||
async def get_system_resources(current_user=None):
|
||||
"""获取服务器资源使用情况"""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
# CPU 使用率
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
|
||||
# 内存使用情况
|
||||
memory = psutil.virtual_memory()
|
||||
memory_total_gb = round(memory.total / BYTES_TO_GB, 2)
|
||||
memory_used_gb = round(memory.used / BYTES_TO_GB, 2)
|
||||
|
||||
# 磁盘使用情况
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_total_gb = round(disk.total / BYTES_TO_GB, 2)
|
||||
disk_used_gb = round(disk.used / BYTES_TO_GB, 2)
|
||||
|
||||
resources = {
|
||||
"cpu": {
|
||||
"percent": cpu_percent,
|
||||
"count": cpu_count
|
||||
},
|
||||
"memory": {
|
||||
"total_gb": memory_total_gb,
|
||||
"used_gb": memory_used_gb,
|
||||
"percent": memory.percent
|
||||
},
|
||||
"disk": {
|
||||
"total_gb": disk_total_gb,
|
||||
"used_gb": disk_used_gb,
|
||||
"percent": disk.percent
|
||||
},
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return create_api_response(code="200", message="获取系统资源成功", data=resources)
|
||||
|
||||
except ImportError:
|
||||
return create_api_response(
|
||||
code="500",
|
||||
message="psutil 库未安装,请运行: pip install psutil"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"获取系统资源失败: {e}")
|
||||
return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}")
|
||||
|
||||
|
||||
async def get_user_stats(current_user=None):
|
||||
"""获取用户统计列表"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 查询所有用户及其会议统计和最后登录时间(排除没有会议的用户)
|
||||
query = """
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.username,
|
||||
u.caption,
|
||||
u.created_at,
|
||||
(SELECT MAX(created_at) FROM user_logs
|
||||
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
|
||||
COUNT(DISTINCT m.meeting_id) as meeting_count,
|
||||
COALESCE(SUM(af.duration), 0) as total_duration_seconds
|
||||
FROM sys_users u
|
||||
INNER JOIN meetings m ON u.user_id = m.user_id
|
||||
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
|
||||
GROUP BY u.user_id, u.username, u.caption, u.created_at
|
||||
HAVING meeting_count > 0
|
||||
ORDER BY u.user_id ASC
|
||||
"""
|
||||
|
||||
cursor.execute(query)
|
||||
users = cursor.fetchall()
|
||||
|
||||
# 格式化返回数据
|
||||
users_list = []
|
||||
for user in users:
|
||||
total_seconds = int(user['total_duration_seconds']) if user['total_duration_seconds'] else 0
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
|
||||
users_list.append({
|
||||
'user_id': user['user_id'],
|
||||
'username': user['username'],
|
||||
'caption': user['caption'],
|
||||
'created_at': user['created_at'].isoformat() if user['created_at'] else None,
|
||||
'last_login_time': user['last_login_time'].isoformat() if user['last_login_time'] else None,
|
||||
'meeting_count': user['meeting_count'],
|
||||
'total_duration_seconds': total_seconds,
|
||||
'total_duration_formatted': f"{hours}h {minutes}m" if total_seconds > 0 else '-'
|
||||
})
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户统计成功",
|
||||
data={"users": users_list, "total": len(users_list)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户统计失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return create_api_response(code="500", message=f"获取用户统计失败: {str(e)}")
|
||||
|
|
@ -0,0 +1,550 @@
|
|||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.response import create_api_response
|
||||
from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo
|
||||
|
||||
|
||||
_USER_MENU_CACHE_TTL_SECONDS = 120
|
||||
_USER_MENU_CACHE_VERSION = "menu-rules-v4"
|
||||
_user_menu_cache_by_role: dict[int, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _get_cached_user_menus(role_id: int):
|
||||
cached = _user_menu_cache_by_role.get(role_id)
|
||||
if not cached:
|
||||
return None
|
||||
if cached.get("version") != _USER_MENU_CACHE_VERSION:
|
||||
_user_menu_cache_by_role.pop(role_id, None)
|
||||
return None
|
||||
if time.time() > cached["expires_at"]:
|
||||
_user_menu_cache_by_role.pop(role_id, None)
|
||||
return None
|
||||
return cached["menus"]
|
||||
|
||||
|
||||
def _set_cached_user_menus(role_id: int, menus):
|
||||
_user_menu_cache_by_role[role_id] = {
|
||||
"version": _USER_MENU_CACHE_VERSION,
|
||||
"menus": menus,
|
||||
"expires_at": time.time() + _USER_MENU_CACHE_TTL_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
def _invalidate_user_menu_cache(role_id: int | None = None):
|
||||
if role_id is None:
|
||||
_user_menu_cache_by_role.clear()
|
||||
return
|
||||
_user_menu_cache_by_role.pop(role_id, None)
|
||||
|
||||
|
||||
def _build_menu_index(menus):
|
||||
menu_by_id = {}
|
||||
children_by_parent = {}
|
||||
for menu in menus:
|
||||
menu_id = menu["menu_id"]
|
||||
menu_by_id[menu_id] = menu
|
||||
parent_id = menu.get("parent_id")
|
||||
if parent_id is not None:
|
||||
children_by_parent.setdefault(parent_id, []).append(menu_id)
|
||||
return menu_by_id, children_by_parent
|
||||
|
||||
|
||||
def _get_descendants(menu_id, children_by_parent):
|
||||
result = set()
|
||||
stack = [menu_id]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
for child_id in children_by_parent.get(current, []):
|
||||
if child_id in result:
|
||||
continue
|
||||
result.add(child_id)
|
||||
stack.append(child_id)
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_permission_menu_ids(raw_menu_ids, all_menus):
|
||||
menu_by_id, children_by_parent = _build_menu_index(all_menus)
|
||||
selected = {menu_id for menu_id in raw_menu_ids if menu_id in menu_by_id}
|
||||
|
||||
expanded = set(selected)
|
||||
|
||||
for menu_id in list(expanded):
|
||||
expanded.update(_get_descendants(menu_id, children_by_parent))
|
||||
|
||||
for menu_id in list(expanded):
|
||||
cursor = menu_by_id[menu_id].get("parent_id")
|
||||
while cursor is not None and cursor in menu_by_id:
|
||||
if cursor in expanded:
|
||||
break
|
||||
expanded.add(cursor)
|
||||
cursor = menu_by_id[cursor].get("parent_id")
|
||||
|
||||
return sorted(expanded)
|
||||
|
||||
|
||||
def get_all_menus():
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
|
||||
parent_id, sort_order, is_active, description, created_at, updated_at
|
||||
FROM sys_menus
|
||||
ORDER BY
|
||||
COALESCE(parent_id, menu_id) ASC,
|
||||
CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END ASC,
|
||||
sort_order ASC,
|
||||
menu_id ASC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
menus = cursor.fetchall()
|
||||
menu_list = [MenuInfo(**menu) for menu in menus]
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取菜单列表成功",
|
||||
data=MenuListResponse(menus=menu_list, total=len(menu_list)),
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
|
||||
|
||||
|
||||
def create_menu(request):
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_code = %s", (request.menu_code,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="菜单编码已存在")
|
||||
|
||||
if request.parent_id is not None:
|
||||
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="400", message="父菜单不存在")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sys_menus (menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order, is_active, description)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
request.menu_code,
|
||||
request.menu_name,
|
||||
request.menu_icon,
|
||||
request.menu_url,
|
||||
request.menu_type,
|
||||
request.parent_id,
|
||||
request.sort_order,
|
||||
1 if request.is_active else 0,
|
||||
request.description,
|
||||
),
|
||||
)
|
||||
menu_id = cursor.lastrowid
|
||||
connection.commit()
|
||||
_invalidate_user_menu_cache()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
|
||||
parent_id, sort_order, is_active, description, created_at, updated_at
|
||||
FROM sys_menus
|
||||
WHERE menu_id = %s
|
||||
""",
|
||||
(menu_id,),
|
||||
)
|
||||
created = cursor.fetchone()
|
||||
return create_api_response(code="200", message="创建菜单成功", data=created)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"创建菜单失败: {str(e)}")
|
||||
|
||||
|
||||
def update_menu(menu_id: int, request):
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT * FROM sys_menus WHERE menu_id = %s", (menu_id,))
|
||||
current = cursor.fetchone()
|
||||
if not current:
|
||||
return create_api_response(code="404", message="菜单不存在")
|
||||
|
||||
updates = {}
|
||||
for field in [
|
||||
"menu_code",
|
||||
"menu_name",
|
||||
"menu_icon",
|
||||
"menu_url",
|
||||
"menu_type",
|
||||
"sort_order",
|
||||
"description",
|
||||
]:
|
||||
value = getattr(request, field)
|
||||
if value is not None:
|
||||
updates[field] = value
|
||||
|
||||
if request.is_active is not None:
|
||||
updates["is_active"] = 1 if request.is_active else 0
|
||||
|
||||
fields_set = getattr(request, "model_fields_set", getattr(request, "__fields_set__", set()))
|
||||
|
||||
if request.parent_id == menu_id:
|
||||
return create_api_response(code="400", message="父菜单不能为自身")
|
||||
if request.parent_id is not None:
|
||||
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (request.parent_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="400", message="父菜单不存在")
|
||||
|
||||
cursor.execute("SELECT menu_id, parent_id FROM sys_menus")
|
||||
all_menus = cursor.fetchall()
|
||||
_, children_by_parent = _build_menu_index(all_menus)
|
||||
descendants = _get_descendants(menu_id, children_by_parent)
|
||||
if request.parent_id in descendants:
|
||||
return create_api_response(code="400", message="父菜单不能设置为当前菜单的子孙菜单")
|
||||
|
||||
if request.parent_id is not None or (request.parent_id is None and "parent_id" in fields_set):
|
||||
updates["parent_id"] = request.parent_id
|
||||
|
||||
if "menu_code" in updates:
|
||||
cursor.execute(
|
||||
"SELECT menu_id FROM sys_menus WHERE menu_code = %s AND menu_id != %s",
|
||||
(updates["menu_code"], menu_id),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="菜单编码已存在")
|
||||
|
||||
if not updates:
|
||||
return create_api_response(code="200", message="没有变更内容", data=current)
|
||||
|
||||
set_sql = ", ".join([f"{key} = %s" for key in updates.keys()])
|
||||
values = list(updates.values()) + [menu_id]
|
||||
cursor.execute(f"UPDATE sys_menus SET {set_sql} WHERE menu_id = %s", tuple(values))
|
||||
connection.commit()
|
||||
_invalidate_user_menu_cache()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
|
||||
parent_id, sort_order, is_active, description, created_at, updated_at
|
||||
FROM sys_menus
|
||||
WHERE menu_id = %s
|
||||
""",
|
||||
(menu_id,),
|
||||
)
|
||||
updated = cursor.fetchone()
|
||||
return create_api_response(code="200", message="更新菜单成功", data=updated)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新菜单失败: {str(e)}")
|
||||
|
||||
|
||||
def delete_menu(menu_id: int):
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("SELECT menu_id FROM sys_menus WHERE menu_id = %s", (menu_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="菜单不存在")
|
||||
|
||||
cursor.execute("SELECT COUNT(*) AS cnt FROM sys_menus WHERE parent_id = %s", (menu_id,))
|
||||
child_count = cursor.fetchone()["cnt"]
|
||||
if child_count > 0:
|
||||
return create_api_response(code="400", message="请先删除子菜单")
|
||||
|
||||
cursor.execute("DELETE FROM sys_role_menu_permissions WHERE menu_id = %s", (menu_id,))
|
||||
cursor.execute("DELETE FROM sys_menus WHERE menu_id = %s", (menu_id,))
|
||||
connection.commit()
|
||||
_invalidate_user_menu_cache()
|
||||
return create_api_response(code="200", message="删除菜单成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除菜单失败: {str(e)}")
|
||||
|
||||
|
||||
def get_all_roles():
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT r.role_id, r.role_name, r.created_at,
|
||||
COUNT(rmp.menu_id) as menu_count
|
||||
FROM sys_roles r
|
||||
LEFT JOIN sys_role_menu_permissions rmp ON r.role_id = rmp.role_id
|
||||
GROUP BY r.role_id
|
||||
ORDER BY r.role_id ASC
|
||||
"""
|
||||
cursor.execute(query)
|
||||
roles = cursor.fetchall()
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取角色列表成功",
|
||||
data={"roles": roles, "total": len(roles)},
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
|
||||
|
||||
|
||||
def create_role(request):
|
||||
try:
|
||||
role_name = request.role_name.strip()
|
||||
if not role_name:
|
||||
return create_api_response(code="400", message="角色名称不能为空")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s", (role_name,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="角色名称已存在")
|
||||
|
||||
cursor.execute("INSERT INTO sys_roles (role_name) VALUES (%s)", (role_name,))
|
||||
role_id = cursor.lastrowid
|
||||
connection.commit()
|
||||
|
||||
cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,))
|
||||
role = cursor.fetchone()
|
||||
return create_api_response(code="200", message="创建角色成功", data=role)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"创建角色失败: {str(e)}")
|
||||
|
||||
|
||||
def update_role(role_id: int, request):
|
||||
try:
|
||||
role_name = request.role_name.strip()
|
||||
if not role_name:
|
||||
return create_api_response(code="400", message="角色名称不能为空")
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
cursor.execute("SELECT role_id FROM sys_roles WHERE role_name = %s AND role_id != %s", (role_name, role_id))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="角色名称已存在")
|
||||
|
||||
cursor.execute("UPDATE sys_roles SET role_name = %s WHERE role_id = %s", (role_name, role_id))
|
||||
connection.commit()
|
||||
|
||||
cursor.execute("SELECT role_id, role_name, created_at FROM sys_roles WHERE role_id = %s", (role_id,))
|
||||
role = cursor.fetchone()
|
||||
return create_api_response(code="200", message="更新角色成功", data=role)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新角色失败: {str(e)}")
|
||||
|
||||
|
||||
def get_role_users(role_id: int, page: int, size: int):
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,))
|
||||
role = cursor.fetchone()
|
||||
if not role:
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS total
|
||||
FROM sys_users
|
||||
WHERE role_id = %s
|
||||
""",
|
||||
(role_id,),
|
||||
)
|
||||
total = cursor.fetchone()["total"]
|
||||
offset = (page - 1) * size
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT user_id, username, caption, email, avatar_url, role_id, created_at
|
||||
FROM sys_users
|
||||
WHERE role_id = %s
|
||||
ORDER BY user_id ASC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(role_id, size, offset),
|
||||
)
|
||||
users = cursor.fetchall()
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取角色用户成功",
|
||||
data={
|
||||
"role_id": role_id,
|
||||
"role_name": role["role_name"],
|
||||
"users": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色用户失败: {str(e)}")
|
||||
|
||||
|
||||
def get_all_role_permissions():
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT rmp.role_id, rmp.menu_id
|
||||
FROM sys_role_menu_permissions rmp
|
||||
JOIN sys_menus m ON m.menu_id = rmp.menu_id
|
||||
WHERE m.is_active = 1
|
||||
ORDER BY rmp.role_id ASC, rmp.menu_id ASC
|
||||
"""
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = {}
|
||||
for row in rows:
|
||||
result.setdefault(row["role_id"], []).append(row["menu_id"])
|
||||
return create_api_response(code="200", message="获取角色权限成功", data={"permissions": result})
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
|
||||
|
||||
|
||||
def get_role_permissions(role_id: int):
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT role_id, role_name FROM sys_roles WHERE role_id = %s", (role_id,))
|
||||
role = cursor.fetchone()
|
||||
if not role:
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
query = """
|
||||
SELECT rmp.menu_id
|
||||
FROM sys_role_menu_permissions rmp
|
||||
JOIN sys_menus m ON m.menu_id = rmp.menu_id
|
||||
WHERE rmp.role_id = %s
|
||||
AND m.is_active = 1
|
||||
"""
|
||||
cursor.execute(query, (role_id,))
|
||||
permissions = cursor.fetchall()
|
||||
menu_ids = [permission["menu_id"] for permission in permissions]
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取角色权限成功",
|
||||
data=RolePermissionInfo(
|
||||
role_id=role["role_id"],
|
||||
role_name=role["role_name"],
|
||||
menu_ids=menu_ids,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
|
||||
|
||||
|
||||
def update_role_permissions(role_id: int, request):
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT role_id FROM sys_roles WHERE role_id = %s", (role_id,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="角色不存在")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT menu_id, parent_id
|
||||
FROM sys_menus
|
||||
WHERE is_active = 1
|
||||
"""
|
||||
)
|
||||
all_menus = cursor.fetchall()
|
||||
menu_id_set = {menu["menu_id"] for menu in all_menus}
|
||||
|
||||
invalid_menu_ids = [menu_id for menu_id in request.menu_ids if menu_id not in menu_id_set]
|
||||
if invalid_menu_ids:
|
||||
return create_api_response(code="400", message="包含无效的菜单ID")
|
||||
|
||||
normalized_menu_ids = _normalize_permission_menu_ids(request.menu_ids, all_menus)
|
||||
|
||||
cursor.execute("DELETE FROM sys_role_menu_permissions WHERE role_id = %s", (role_id,))
|
||||
if normalized_menu_ids:
|
||||
insert_values = [(role_id, menu_id) for menu_id in normalized_menu_ids]
|
||||
cursor.executemany(
|
||||
"INSERT INTO sys_role_menu_permissions (role_id, menu_id) VALUES (%s, %s)",
|
||||
insert_values,
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
_invalidate_user_menu_cache(role_id)
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="更新角色权限成功",
|
||||
data={"role_id": role_id, "menu_count": len(normalized_menu_ids)},
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}")
|
||||
|
||||
|
||||
def get_user_menus(current_user: dict):
|
||||
try:
|
||||
role_id = current_user["role_id"]
|
||||
cached_menus = _get_cached_user_menus(role_id)
|
||||
if cached_menus is not None:
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户菜单成功",
|
||||
data={"menus": cached_menus},
|
||||
)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
|
||||
m.menu_url, m.menu_type, m.parent_id, m.sort_order
|
||||
FROM sys_menus m
|
||||
JOIN sys_role_menu_permissions rmp ON m.menu_id = rmp.menu_id
|
||||
WHERE rmp.role_id = %s
|
||||
AND m.is_active = 1
|
||||
AND (m.is_visible = 1 OR m.is_visible IS NULL OR m.menu_code IN ('dashboard', 'desktop'))
|
||||
ORDER BY
|
||||
COALESCE(m.parent_id, m.menu_id) ASC,
|
||||
CASE WHEN m.parent_id IS NULL THEN 0 ELSE 1 END ASC,
|
||||
m.sort_order ASC,
|
||||
m.menu_id ASC
|
||||
"""
|
||||
cursor.execute(query, (role_id,))
|
||||
menus = cursor.fetchall()
|
||||
|
||||
current_menu_ids = {menu["menu_id"] for menu in menus}
|
||||
missing_parent_ids = {
|
||||
menu["parent_id"]
|
||||
for menu in menus
|
||||
if menu.get("parent_id") is not None and menu["parent_id"] not in current_menu_ids
|
||||
}
|
||||
|
||||
if missing_parent_ids:
|
||||
format_strings = ",".join(["%s"] * len(missing_parent_ids))
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type, parent_id, sort_order
|
||||
FROM sys_menus
|
||||
WHERE is_active = 1 AND menu_id IN ({format_strings})
|
||||
""",
|
||||
tuple(missing_parent_ids),
|
||||
)
|
||||
parent_rows = cursor.fetchall()
|
||||
menus.extend(parent_rows)
|
||||
|
||||
menus = sorted(
|
||||
{menu["menu_id"]: menu for menu in menus}.values(),
|
||||
key=lambda menu: (
|
||||
menu["parent_id"] if menu["parent_id"] is not None else menu["menu_id"],
|
||||
0 if menu["parent_id"] is None else 1,
|
||||
menu["sort_order"],
|
||||
menu["menu_id"],
|
||||
),
|
||||
)
|
||||
_set_cached_user_menus(role_id, menus)
|
||||
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取用户菜单成功",
|
||||
data={"menus": menus},
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}")
|
||||
|
|
@ -0,0 +1,639 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.response import create_api_response
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.llm_service import LLMService
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
|
||||
|
||||
llm_service = LLMService()
|
||||
transcription_service = AsyncTranscriptionService()
|
||||
|
||||
|
||||
def _validate_parameter_request(request):
|
||||
param_key = str(request.param_key or "").strip()
|
||||
if not param_key:
|
||||
return "参数键不能为空"
|
||||
|
||||
if param_key in SystemConfigService.DEPRECATED_BRANDING_PARAMETER_KEYS:
|
||||
return f"{param_key} 已废弃,现已改为前端固定文案,不再支持配置"
|
||||
|
||||
if param_key == SystemConfigService.TOKEN_EXPIRE_DAYS:
|
||||
if request.category != "system":
|
||||
return "token_expire_days 必须归类为 system"
|
||||
if request.value_type != "number":
|
||||
return "token_expire_days 的值类型必须为 number"
|
||||
try:
|
||||
expire_days = int(str(request.param_value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return "token_expire_days 必须为正整数"
|
||||
if expire_days <= 0:
|
||||
return "token_expire_days 必须大于 0"
|
||||
if expire_days > 365:
|
||||
return "token_expire_days 不能超过 365 天"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_json_object(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_string_list(value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
values = [str(item).strip() for item in value if str(item).strip()]
|
||||
return values or None
|
||||
if isinstance(value, str):
|
||||
values = [item.strip() for item in value.split(",") if item.strip()]
|
||||
return values or None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_int_list(value: Any) -> list[int] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
items = value
|
||||
elif isinstance(value, str):
|
||||
items = [item.strip() for item in value.split(",") if item.strip()]
|
||||
else:
|
||||
return None
|
||||
|
||||
normalized = []
|
||||
for item in items:
|
||||
try:
|
||||
normalized.append(int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _clean_extra_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in (config or {}).items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped:
|
||||
cleaned[key] = stripped
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
normalized_list = []
|
||||
for item in value:
|
||||
if item is None:
|
||||
continue
|
||||
if isinstance(item, str):
|
||||
stripped = item.strip()
|
||||
if stripped:
|
||||
normalized_list.append(stripped)
|
||||
else:
|
||||
normalized_list.append(item)
|
||||
if normalized_list:
|
||||
cleaned[key] = normalized_list
|
||||
continue
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
|
||||
def _merge_audio_extra_config(request, vocabulary_id: str | None = None) -> dict[str, Any]:
|
||||
extra_config = _parse_json_object(request.extra_config)
|
||||
|
||||
if request.audio_scene == "asr":
|
||||
if vocabulary_id:
|
||||
extra_config["vocabulary_id"] = vocabulary_id
|
||||
else:
|
||||
extra_config.pop("vocabulary_id", None)
|
||||
|
||||
merged = dict(extra_config)
|
||||
|
||||
language_hints = _normalize_string_list(merged.get("language_hints"))
|
||||
if language_hints is not None:
|
||||
merged["language_hints"] = language_hints
|
||||
|
||||
channel_id = _normalize_int_list(merged.get("channel_id"))
|
||||
if channel_id is not None:
|
||||
merged["channel_id"] = channel_id
|
||||
|
||||
return _clean_extra_config(merged)
|
||||
|
||||
|
||||
def _normalize_audio_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
extra_config = _parse_json_object(row.get("extra_config"))
|
||||
|
||||
row["extra_config"] = extra_config
|
||||
row["service_model_name"] = extra_config.get("model")
|
||||
row["request_timeout_seconds"] = int(row.get("request_timeout_seconds") or 300)
|
||||
return row
|
||||
|
||||
|
||||
def _resolve_hot_word_vocabulary_id(cursor, request) -> str | None:
|
||||
vocabulary_id = _parse_json_object(request.extra_config).get("vocabulary_id")
|
||||
if request.hot_word_group_id:
|
||||
cursor.execute("SELECT vocabulary_id FROM hot_word_group WHERE id = %s", (request.hot_word_group_id,))
|
||||
group_row = cursor.fetchone()
|
||||
if group_row and group_row.get("vocabulary_id"):
|
||||
vocabulary_id = group_row["vocabulary_id"]
|
||||
return vocabulary_id
|
||||
|
||||
|
||||
def list_parameters(category: str | None = None, keyword: str | None = None):
|
||||
try:
|
||||
SystemConfigService.ensure_builtin_parameters()
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT param_id, param_key, param_name, param_value, value_type, category,
|
||||
description, is_active, created_at, updated_at
|
||||
FROM sys_system_parameters
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
if category:
|
||||
query += " AND category = %s"
|
||||
params.append(category)
|
||||
if keyword:
|
||||
like_pattern = f"%{keyword}%"
|
||||
query += " AND (param_key LIKE %s OR param_name LIKE %s)"
|
||||
params.extend([like_pattern, like_pattern])
|
||||
|
||||
query += " ORDER BY category ASC, param_key ASC"
|
||||
cursor.execute(query, tuple(params))
|
||||
rows = [
|
||||
row for row in cursor.fetchall()
|
||||
if row["param_key"] not in SystemConfigService.DEPRECATED_BRANDING_PARAMETER_KEYS
|
||||
]
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取参数列表成功",
|
||||
data={"items": rows, "total": len(rows)},
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取参数列表失败: {str(e)}")
|
||||
|
||||
|
||||
def get_parameter(param_key: str):
|
||||
try:
|
||||
if param_key in SystemConfigService.DEPRECATED_BRANDING_PARAMETER_KEYS:
|
||||
return create_api_response(code="404", message="参数不存在")
|
||||
|
||||
SystemConfigService.ensure_builtin_parameters()
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT param_id, param_key, param_name, param_value, value_type, category,
|
||||
description, is_active, created_at, updated_at
|
||||
FROM sys_system_parameters
|
||||
WHERE param_key = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(param_key,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return create_api_response(code="404", message="参数不存在")
|
||||
return create_api_response(code="200", message="获取参数成功", data=row)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取参数失败: {str(e)}")
|
||||
|
||||
|
||||
def create_parameter(request):
|
||||
try:
|
||||
validation_error = _validate_parameter_request(request)
|
||||
if validation_error:
|
||||
return create_api_response(code="400", message=validation_error)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (request.param_key,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="参数键已存在")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sys_system_parameters
|
||||
(param_key, param_name, param_value, value_type, category, description, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
request.param_key,
|
||||
request.param_name,
|
||||
request.param_value,
|
||||
request.value_type,
|
||||
request.category,
|
||||
request.description,
|
||||
1 if request.is_active else 0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
SystemConfigService.invalidate_cache()
|
||||
return create_api_response(code="200", message="创建参数成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"创建参数失败: {str(e)}")
|
||||
|
||||
|
||||
def update_parameter(param_key: str, request):
|
||||
try:
|
||||
validation_error = _validate_parameter_request(request)
|
||||
if validation_error:
|
||||
return create_api_response(code="400", message=validation_error)
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="参数不存在")
|
||||
|
||||
new_key = request.param_key or param_key
|
||||
if new_key != param_key:
|
||||
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (new_key,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="新的参数键已存在")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE sys_system_parameters
|
||||
SET param_key = %s, param_name = %s, param_value = %s, value_type = %s,
|
||||
category = %s, description = %s, is_active = %s
|
||||
WHERE param_key = %s
|
||||
""",
|
||||
(
|
||||
new_key,
|
||||
request.param_name,
|
||||
request.param_value,
|
||||
request.value_type,
|
||||
request.category,
|
||||
request.description,
|
||||
1 if request.is_active else 0,
|
||||
param_key,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
SystemConfigService.invalidate_cache()
|
||||
return create_api_response(code="200", message="更新参数成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新参数失败: {str(e)}")
|
||||
|
||||
|
||||
def delete_parameter(param_key: str):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT param_id FROM sys_system_parameters WHERE param_key = %s", (param_key,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="参数不存在")
|
||||
|
||||
cursor.execute("DELETE FROM sys_system_parameters WHERE param_key = %s", (param_key,))
|
||||
conn.commit()
|
||||
SystemConfigService.invalidate_cache()
|
||||
return create_api_response(code="200", message="删除参数成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除参数失败: {str(e)}")
|
||||
|
||||
|
||||
def list_llm_model_configs():
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT config_id, model_code, model_name, provider, endpoint_url, api_key,
|
||||
llm_model_name, llm_timeout, llm_temperature, llm_top_p, llm_max_tokens,
|
||||
llm_system_prompt, description, is_active, is_default, created_at, updated_at
|
||||
FROM llm_model_config
|
||||
ORDER BY model_code ASC
|
||||
"""
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取LLM模型配置成功",
|
||||
data={"items": rows, "total": len(rows)},
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取LLM模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def create_llm_model_config(request):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (request.model_code,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="模型编码已存在")
|
||||
|
||||
cursor.execute("SELECT COUNT(*) AS total FROM llm_model_config")
|
||||
total_row = cursor.fetchone() or {"total": 0}
|
||||
is_default = bool(request.is_default) or total_row["total"] == 0
|
||||
if is_default:
|
||||
cursor.execute("UPDATE llm_model_config SET is_default = 0 WHERE is_default = 1")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO llm_model_config
|
||||
(model_code, model_name, provider, endpoint_url, api_key, llm_model_name,
|
||||
llm_timeout, llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt,
|
||||
description, is_active, is_default)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
request.model_code,
|
||||
request.model_name,
|
||||
request.provider,
|
||||
request.endpoint_url,
|
||||
request.api_key,
|
||||
request.llm_model_name,
|
||||
request.llm_timeout,
|
||||
request.llm_temperature,
|
||||
request.llm_top_p,
|
||||
request.llm_max_tokens,
|
||||
request.llm_system_prompt,
|
||||
request.description,
|
||||
1 if request.is_active else 0,
|
||||
1 if is_default else 0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return create_api_response(code="200", message="创建LLM模型配置成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"创建LLM模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def update_llm_model_config(model_code: str, request):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,))
|
||||
existed = cursor.fetchone()
|
||||
if not existed:
|
||||
return create_api_response(code="404", message="模型配置不存在")
|
||||
|
||||
new_model_code = request.model_code or model_code
|
||||
if new_model_code != model_code:
|
||||
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (new_model_code,))
|
||||
duplicate_row = cursor.fetchone()
|
||||
if duplicate_row and duplicate_row["config_id"] != existed["config_id"]:
|
||||
return create_api_response(code="400", message="新的模型编码已存在")
|
||||
|
||||
if request.is_default:
|
||||
cursor.execute(
|
||||
"UPDATE llm_model_config SET is_default = 0 WHERE model_code <> %s AND is_default = 1",
|
||||
(model_code,),
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE llm_model_config
|
||||
SET model_code = %s, model_name = %s, provider = %s, endpoint_url = %s, api_key = %s,
|
||||
llm_model_name = %s, llm_timeout = %s, llm_temperature = %s, llm_top_p = %s,
|
||||
llm_max_tokens = %s, llm_system_prompt = %s, description = %s, is_active = %s, is_default = %s
|
||||
WHERE model_code = %s
|
||||
""",
|
||||
(
|
||||
new_model_code,
|
||||
request.model_name,
|
||||
request.provider,
|
||||
request.endpoint_url,
|
||||
request.api_key,
|
||||
request.llm_model_name,
|
||||
request.llm_timeout,
|
||||
request.llm_temperature,
|
||||
request.llm_top_p,
|
||||
request.llm_max_tokens,
|
||||
request.llm_system_prompt,
|
||||
request.description,
|
||||
1 if request.is_active else 0,
|
||||
1 if request.is_default else 0,
|
||||
model_code,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return create_api_response(code="200", message="更新LLM模型配置成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新LLM模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def list_audio_model_configs(scene: str = "all"):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
sql = """
|
||||
SELECT a.config_id, a.model_code, a.model_name, a.audio_scene, a.provider, a.endpoint_url, a.api_key,
|
||||
a.request_timeout_seconds, a.hot_word_group_id, a.extra_config,
|
||||
a.description, a.is_active, a.is_default, a.created_at, a.updated_at,
|
||||
g.name AS hot_word_group_name, g.vocabulary_id AS hot_word_group_vocab_id
|
||||
FROM audio_model_config a
|
||||
LEFT JOIN hot_word_group g ON g.id = a.hot_word_group_id
|
||||
"""
|
||||
params = []
|
||||
if scene in ("asr", "voiceprint"):
|
||||
sql += " WHERE a.audio_scene = %s"
|
||||
params.append(scene)
|
||||
sql += " ORDER BY a.audio_scene ASC, a.model_code ASC"
|
||||
cursor.execute(sql, tuple(params))
|
||||
rows = [_normalize_audio_row(row) for row in cursor.fetchall()]
|
||||
return create_api_response(code="200", message="获取音频模型配置成功", data={"items": rows, "total": len(rows)})
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取音频模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def create_audio_model_config(request):
|
||||
try:
|
||||
if request.audio_scene not in ("asr", "voiceprint"):
|
||||
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint")
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (request.model_code,))
|
||||
if cursor.fetchone():
|
||||
return create_api_response(code="400", message="模型编码已存在")
|
||||
|
||||
cursor.execute("SELECT COUNT(*) AS total FROM audio_model_config WHERE audio_scene = %s", (request.audio_scene,))
|
||||
total_row = cursor.fetchone() or {"total": 0}
|
||||
is_default = bool(request.is_default) or total_row["total"] == 0
|
||||
if is_default:
|
||||
cursor.execute(
|
||||
"UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND is_default = 1",
|
||||
(request.audio_scene,),
|
||||
)
|
||||
|
||||
asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request)
|
||||
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO audio_model_config
|
||||
(model_code, model_name, audio_scene, provider, endpoint_url, api_key,
|
||||
request_timeout_seconds, hot_word_group_id, extra_config, description, is_active, is_default)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
request.model_code,
|
||||
request.model_name,
|
||||
request.audio_scene,
|
||||
request.provider,
|
||||
request.endpoint_url,
|
||||
request.api_key,
|
||||
request.request_timeout_seconds,
|
||||
request.hot_word_group_id,
|
||||
json.dumps(extra_config, ensure_ascii=False),
|
||||
request.description,
|
||||
1 if request.is_active else 0,
|
||||
1 if is_default else 0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return create_api_response(code="200", message="创建音频模型配置成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"创建音频模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def update_audio_model_config(model_code: str, request):
|
||||
try:
|
||||
if request.audio_scene not in ("asr", "voiceprint"):
|
||||
return create_api_response(code="400", message="audio_scene 仅支持 asr 或 voiceprint")
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,))
|
||||
existed = cursor.fetchone()
|
||||
if not existed:
|
||||
return create_api_response(code="404", message="模型配置不存在")
|
||||
|
||||
new_model_code = request.model_code or model_code
|
||||
if new_model_code != model_code:
|
||||
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (new_model_code,))
|
||||
duplicate_row = cursor.fetchone()
|
||||
if duplicate_row and duplicate_row["config_id"] != existed["config_id"]:
|
||||
return create_api_response(code="400", message="新的模型编码已存在")
|
||||
|
||||
if request.is_default:
|
||||
cursor.execute(
|
||||
"UPDATE audio_model_config SET is_default = 0 WHERE audio_scene = %s AND model_code <> %s AND is_default = 1",
|
||||
(request.audio_scene, model_code),
|
||||
)
|
||||
|
||||
asr_vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request)
|
||||
extra_config = _merge_audio_extra_config(request, vocabulary_id=asr_vocabulary_id)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE audio_model_config
|
||||
SET model_code = %s, model_name = %s, audio_scene = %s, provider = %s, endpoint_url = %s, api_key = %s,
|
||||
request_timeout_seconds = %s, hot_word_group_id = %s, extra_config = %s,
|
||||
description = %s, is_active = %s, is_default = %s
|
||||
WHERE model_code = %s
|
||||
""",
|
||||
(
|
||||
new_model_code,
|
||||
request.model_name,
|
||||
request.audio_scene,
|
||||
request.provider,
|
||||
request.endpoint_url,
|
||||
request.api_key,
|
||||
request.request_timeout_seconds,
|
||||
request.hot_word_group_id,
|
||||
json.dumps(extra_config, ensure_ascii=False),
|
||||
request.description,
|
||||
1 if request.is_active else 0,
|
||||
1 if request.is_default else 0,
|
||||
model_code,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return create_api_response(code="200", message="更新音频模型配置成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"更新音频模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def delete_llm_model_config(model_code: str):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT config_id FROM llm_model_config WHERE model_code = %s", (model_code,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="模型配置不存在")
|
||||
|
||||
cursor.execute("DELETE FROM llm_model_config WHERE model_code = %s", (model_code,))
|
||||
conn.commit()
|
||||
return create_api_response(code="200", message="删除LLM模型配置成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除LLM模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def delete_audio_model_config(model_code: str):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT config_id FROM audio_model_config WHERE model_code = %s", (model_code,))
|
||||
if not cursor.fetchone():
|
||||
return create_api_response(code="404", message="模型配置不存在")
|
||||
|
||||
cursor.execute("DELETE FROM audio_model_config WHERE model_code = %s", (model_code,))
|
||||
conn.commit()
|
||||
return create_api_response(code="200", message="删除音频模型配置成功")
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"删除音频模型配置失败: {str(e)}")
|
||||
|
||||
|
||||
def test_llm_model_config(request):
|
||||
try:
|
||||
payload = request.model_dump() if hasattr(request, "model_dump") else request.dict()
|
||||
result = llm_service.test_model(payload, prompt=request.test_prompt)
|
||||
return create_api_response(code="200", message="LLM模型测试成功", data=result)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"LLM模型测试失败: {str(e)}")
|
||||
|
||||
|
||||
def test_audio_model_config(request):
|
||||
try:
|
||||
if request.audio_scene != "asr":
|
||||
return create_api_response(code="400", message="当前仅支持音频识别(ASR)测试")
|
||||
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
vocabulary_id = _resolve_hot_word_vocabulary_id(cursor, request)
|
||||
|
||||
extra_config = _merge_audio_extra_config(request, vocabulary_id=vocabulary_id)
|
||||
runtime_config = {
|
||||
"provider": request.provider,
|
||||
"endpoint_url": request.endpoint_url,
|
||||
"api_key": request.api_key,
|
||||
"audio_scene": request.audio_scene,
|
||||
"hot_word_group_id": request.hot_word_group_id,
|
||||
"request_timeout_seconds": request.request_timeout_seconds,
|
||||
**extra_config,
|
||||
}
|
||||
result = transcription_service.test_asr_model(runtime_config, test_file_url=request.test_file_url)
|
||||
return create_api_response(code="200", message="音频模型测试任务已提交", data=result)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"音频模型测试失败: {str(e)}")
|
||||
|
||||
|
||||
def get_public_system_config():
|
||||
try:
|
||||
return create_api_response(
|
||||
code="200",
|
||||
message="获取公开配置成功",
|
||||
data=SystemConfigService.get_public_configs(),
|
||||
)
|
||||
except Exception as e:
|
||||
return create_api_response(code="500", message=f"获取公开配置失败: {str(e)}")
|
||||
|
|
@ -8,7 +8,7 @@ from typing import Optional, Dict, Any, List
|
|||
|
||||
import redis
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.llm_service import LLMService
|
||||
from app.services.llm_service import LLMService, LLMServiceError
|
||||
|
||||
class AsyncKnowledgeBaseService:
|
||||
"""异步知识库服务类 - 处理知识库相关的异步任务"""
|
||||
|
|
@ -45,7 +45,7 @@ class AsyncKnowledgeBaseService:
|
|||
"""
|
||||
cursor.execute(query, (task_id, user_id, kb_id, user_prompt, prompt_id))
|
||||
else:
|
||||
# Fallback to the old method if no cursor is provided
|
||||
# 无外部事务时,使用服务内的持久化入口。
|
||||
self._save_task_to_db(task_id, user_id, kb_id, user_prompt, prompt_id)
|
||||
|
||||
current_time = datetime.now().isoformat()
|
||||
|
|
@ -96,9 +96,7 @@ class AsyncKnowledgeBaseService:
|
|||
|
||||
# 4. 调用LLM API
|
||||
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在生成知识库...")
|
||||
generated_content = self.llm_service._call_llm_api(full_prompt)
|
||||
if not generated_content:
|
||||
raise Exception("LLM API调用失败或返回空内容")
|
||||
generated_content = self.llm_service.call_llm_api_or_raise(full_prompt)
|
||||
|
||||
# 5. 保存结果到数据库
|
||||
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存结果...")
|
||||
|
|
@ -110,6 +108,11 @@ class AsyncKnowledgeBaseService:
|
|||
|
||||
print(f"Task {task_id} completed successfully")
|
||||
|
||||
except LLMServiceError as e:
|
||||
error_msg = e.message or str(e)
|
||||
print(f"Task {task_id} failed with LLM error: {error_msg}")
|
||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
||||
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
print(f"Task {task_id} failed: {error_msg}")
|
||||
|
|
|
|||
|
|
@ -1,17 +1,30 @@
|
|||
"""
|
||||
异步会议服务 - 处理会议总结生成的异步任务
|
||||
采用FastAPI BackgroundTasks模式
|
||||
采用受控线程池执行,避免阻塞 Web 请求进程
|
||||
"""
|
||||
import uuid
|
||||
import time
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
import redis
|
||||
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG
|
||||
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR, BASE_DIR
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.llm_service import LLMService
|
||||
from app.services.background_task_runner import KeyedBackgroundTaskRunner
|
||||
from app.services.llm_service import LLMService, LLMServiceError
|
||||
|
||||
|
||||
summary_task_runner = KeyedBackgroundTaskRunner(
|
||||
max_workers=BACKGROUND_TASK_CONFIG['summary_workers'],
|
||||
thread_name_prefix="imeeting-summary",
|
||||
)
|
||||
monitor_task_runner = KeyedBackgroundTaskRunner(
|
||||
max_workers=BACKGROUND_TASK_CONFIG['monitor_workers'],
|
||||
thread_name_prefix="imeeting-monitor",
|
||||
)
|
||||
|
||||
class AsyncMeetingService:
|
||||
"""异步会议服务类 - 处理会议相关的异步任务"""
|
||||
|
|
@ -23,14 +36,48 @@ class AsyncMeetingService:
|
|||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
self.llm_service = LLMService() # 复用现有的同步LLM服务
|
||||
|
||||
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None) -> str:
|
||||
def enqueue_summary_generation(
|
||||
self,
|
||||
meeting_id: int,
|
||||
user_prompt: str = "",
|
||||
prompt_id: Optional[int] = None,
|
||||
model_code: Optional[str] = None
|
||||
) -> tuple[str, bool]:
|
||||
"""创建并提交总结任务;若已有运行中的同会议总结任务,则直接返回现有任务。"""
|
||||
existing_task = self._get_existing_summary_task(meeting_id)
|
||||
if existing_task:
|
||||
return existing_task, False
|
||||
|
||||
task_id = self.start_summary_generation(meeting_id, user_prompt, prompt_id, model_code)
|
||||
summary_task_runner.submit(f"meeting-summary:{task_id}", self._process_task, task_id)
|
||||
return task_id, True
|
||||
|
||||
def enqueue_transcription_monitor(
|
||||
self,
|
||||
meeting_id: int,
|
||||
transcription_task_id: str,
|
||||
prompt_id: Optional[int] = None,
|
||||
model_code: Optional[str] = None
|
||||
) -> bool:
|
||||
"""提交转录监控任务,避免同一转录任务重复轮询。"""
|
||||
return monitor_task_runner.submit(
|
||||
f"transcription-monitor:{transcription_task_id}",
|
||||
self.monitor_and_auto_summarize,
|
||||
meeting_id,
|
||||
transcription_task_id,
|
||||
prompt_id,
|
||||
model_code,
|
||||
)
|
||||
|
||||
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None, model_code: Optional[str] = None) -> str:
|
||||
"""
|
||||
创建异步总结任务,任务的执行将由外部(如API层的BackgroundTasks)触发。
|
||||
创建异步总结任务,任务的执行将由后台线程池触发。
|
||||
|
||||
Args:
|
||||
meeting_id: 会议ID
|
||||
user_prompt: 用户额外提示词
|
||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||
model_code: 可选的LLM模型编码,如果不指定则使用默认模型
|
||||
|
||||
Returns:
|
||||
str: 任务ID
|
||||
|
|
@ -40,7 +87,7 @@ class AsyncMeetingService:
|
|||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 在数据库中创建任务记录
|
||||
self._save_task_to_db(task_id, meeting_id, user_prompt, prompt_id)
|
||||
self._save_task_to_db(task_id, meeting_id, user_prompt, prompt_id, model_code)
|
||||
|
||||
# 将任务详情存入Redis,用于快速查询状态
|
||||
current_time = datetime.now().isoformat()
|
||||
|
|
@ -49,6 +96,7 @@ class AsyncMeetingService:
|
|||
'meeting_id': str(meeting_id),
|
||||
'user_prompt': user_prompt,
|
||||
'prompt_id': str(prompt_id) if prompt_id else '',
|
||||
'model_code': model_code or '',
|
||||
'status': 'pending',
|
||||
'progress': '0',
|
||||
'created_at': current_time,
|
||||
|
|
@ -61,13 +109,15 @@ class AsyncMeetingService:
|
|||
|
||||
except Exception as e:
|
||||
print(f"Error starting summary generation: {e}")
|
||||
raise e
|
||||
raise
|
||||
|
||||
def _process_task(self, task_id: str):
|
||||
"""
|
||||
处理单个异步任务的函数,设计为由BackgroundTasks调用。
|
||||
处理单个异步任务的函数,在线程池中执行。
|
||||
"""
|
||||
print(f"Background task started for meeting summary task: {task_id}")
|
||||
lock_token = None
|
||||
lock_key = f"lock:meeting-summary-task:{task_id}"
|
||||
try:
|
||||
# 从Redis获取任务数据
|
||||
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
|
||||
|
|
@ -79,6 +129,11 @@ class AsyncMeetingService:
|
|||
user_prompt = task_data.get('user_prompt', '')
|
||||
prompt_id_str = task_data.get('prompt_id', '')
|
||||
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
|
||||
model_code = task_data.get('model_code', '') or None
|
||||
lock_token = self._acquire_lock(lock_key, ttl_seconds=7200)
|
||||
if not lock_token:
|
||||
print(f"Task {task_id} is already being processed, skipping duplicate execution")
|
||||
return
|
||||
|
||||
# 1. 更新状态为processing
|
||||
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
||||
|
|
@ -89,33 +144,47 @@ class AsyncMeetingService:
|
|||
if not transcript_text:
|
||||
raise Exception("无法获取会议转录内容")
|
||||
|
||||
# 3. 构建提示词
|
||||
# 3. 构建消息
|
||||
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
|
||||
full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id)
|
||||
messages = self._build_messages(meeting_id, transcript_text, user_prompt, prompt_id)
|
||||
|
||||
# 4. 调用LLM API
|
||||
# 4. 调用LLM API(支持指定模型)
|
||||
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
|
||||
summary_content = self.llm_service._call_llm_api(full_prompt)
|
||||
if not summary_content:
|
||||
raise Exception("LLM API调用失败或返回空内容")
|
||||
summary_content = self.llm_service.call_llm_api_messages_or_raise(messages, model_code=model_code)
|
||||
|
||||
# 5. 保存结果到主表
|
||||
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存总结结果...")
|
||||
self._update_task_status_in_redis(task_id, 'processing', 90, message="保存总结结果...")
|
||||
self._save_summary_to_db(meeting_id, summary_content, user_prompt, prompt_id)
|
||||
|
||||
# 6. 任务完成
|
||||
self._update_task_in_db(task_id, 'completed', 100, result=summary_content)
|
||||
self._update_task_status_in_redis(task_id, 'completed', 100, result=summary_content)
|
||||
# 6. 导出MD文件到音频同目录
|
||||
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
|
||||
md_path = self._export_summary_md(meeting_id, summary_content, task_id=task_id)
|
||||
if not md_path:
|
||||
raise RuntimeError("导出Markdown文件失败:未生成文件路径")
|
||||
|
||||
# 7. 任务完成,result保存MD文件路径
|
||||
self._update_task_in_db(task_id, 'completed', 100, result=md_path)
|
||||
self._update_task_status_in_redis(task_id, 'completed', 100, message="任务已完成", result=md_path)
|
||||
print(f"Task {task_id} completed successfully")
|
||||
|
||||
except LLMServiceError as e:
|
||||
error_msg = e.message or str(e)
|
||||
print(f"Task {task_id} failed with LLM error: {error_msg}")
|
||||
self._mark_task_failed(task_id, error_msg)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
print(f"Task {task_id} failed: {error_msg}")
|
||||
# 更新失败状态
|
||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
||||
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
|
||||
self._mark_task_failed(task_id, error_msg)
|
||||
finally:
|
||||
self._release_lock(lock_key, lock_token)
|
||||
|
||||
def monitor_and_auto_summarize(self, meeting_id: int, transcription_task_id: str, prompt_id: Optional[int] = None):
|
||||
def monitor_and_auto_summarize(
|
||||
self,
|
||||
meeting_id: int,
|
||||
transcription_task_id: str,
|
||||
prompt_id: Optional[int] = None,
|
||||
model_code: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
监控转录任务,完成后自动生成总结
|
||||
此方法设计为由BackgroundTasks调用,在后台运行
|
||||
|
|
@ -124,18 +193,24 @@ class AsyncMeetingService:
|
|||
meeting_id: 会议ID
|
||||
transcription_task_id: 转录任务ID
|
||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||
|
||||
流程:
|
||||
1. 循环轮询转录任务状态
|
||||
2. 转录成功后自动启动总结任务
|
||||
3. 转录失败或超时则停止轮询并记录日志
|
||||
"""
|
||||
print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}")
|
||||
print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
|
||||
|
||||
# 获取配置参数
|
||||
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
|
||||
max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time']
|
||||
max_polls = max_wait_time // poll_interval
|
||||
lock_key = f"lock:transcription-monitor:{transcription_task_id}"
|
||||
lock_token = self._acquire_lock(lock_key, ttl_seconds=max_wait_time + poll_interval)
|
||||
if not lock_token:
|
||||
print(f"[Monitor] Monitor task already running for transcription task {transcription_task_id}, skipping duplicate worker")
|
||||
return
|
||||
|
||||
# 延迟导入以避免循环导入
|
||||
transcription_service = AsyncTranscriptionService()
|
||||
|
|
@ -166,11 +241,16 @@ class AsyncMeetingService:
|
|||
else:
|
||||
# 启动总结任务
|
||||
try:
|
||||
summary_task_id = self.start_summary_generation(meeting_id, user_prompt="", prompt_id=prompt_id)
|
||||
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
|
||||
|
||||
# 在后台执行总结任务
|
||||
self._process_task(summary_task_id)
|
||||
summary_task_id, created = self.enqueue_summary_generation(
|
||||
meeting_id,
|
||||
user_prompt="",
|
||||
prompt_id=prompt_id,
|
||||
model_code=model_code
|
||||
)
|
||||
if created:
|
||||
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
|
||||
else:
|
||||
print(f"[Monitor] Reused existing summary task {summary_task_id} for meeting {meeting_id}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to start summary generation: {e}"
|
||||
|
|
@ -207,9 +287,44 @@ class AsyncMeetingService:
|
|||
|
||||
except Exception as e:
|
||||
print(f"[Monitor] Fatal error in monitor_and_auto_summarize: {e}")
|
||||
finally:
|
||||
self._release_lock(lock_key, lock_token)
|
||||
|
||||
# --- 会议相关方法 ---
|
||||
|
||||
def _export_summary_md(self, meeting_id: int, summary_content: str, task_id: Optional[str] = None) -> str:
|
||||
"""将总结内容导出为MD文件,保存到音频同目录,返回 /uploads/... 相对路径"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT title FROM meetings WHERE meeting_id = %s", (meeting_id,))
|
||||
meeting = cursor.fetchone()
|
||||
cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,))
|
||||
audio = cursor.fetchone()
|
||||
|
||||
title = meeting['title'] if meeting else f"meeting_{meeting_id}"
|
||||
if audio and audio.get('file_path'):
|
||||
audio_path = BASE_DIR / str(audio['file_path']).lstrip('/')
|
||||
md_dir = audio_path.parent
|
||||
else:
|
||||
md_dir = AUDIO_DIR / str(meeting_id)
|
||||
|
||||
md_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip()
|
||||
if not safe_title:
|
||||
safe_title = f"meeting_{meeting_id}"
|
||||
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
task_suffix = ""
|
||||
if task_id:
|
||||
task_suffix = f"_{str(task_id).replace('-', '')[:8]}"
|
||||
md_path = md_dir / f"{safe_title}_总结_{timestamp_suffix}{task_suffix}.md"
|
||||
md_path.write_text(summary_content, encoding='utf-8')
|
||||
relative_md_path = "/" + str(md_path.relative_to(BASE_DIR)).replace("\\", "/")
|
||||
print(f"总结MD文件已保存: {relative_md_path}")
|
||||
return relative_md_path
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"导出总结MD文件失败: {e}") from e
|
||||
|
||||
def _get_meeting_transcript(self, meeting_id: int) -> str:
|
||||
"""从数据库获取会议转录内容"""
|
||||
try:
|
||||
|
|
@ -241,49 +356,176 @@ class AsyncMeetingService:
|
|||
print(f"获取会议转录内容错误: {e}")
|
||||
return ""
|
||||
|
||||
def _build_prompt(self, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str:
|
||||
def _get_meeting_prompt_context(self, meeting_id: int) -> Dict[str, Any]:
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT m.title, m.meeting_time, u.caption AS creator_name
|
||||
FROM meetings m
|
||||
LEFT JOIN sys_users u ON m.user_id = u.user_id
|
||||
WHERE m.meeting_id = %s
|
||||
""",
|
||||
(meeting_id,)
|
||||
)
|
||||
meeting = cursor.fetchone()
|
||||
|
||||
if not meeting:
|
||||
cursor.close()
|
||||
return {}
|
||||
|
||||
meeting_time = meeting.get('meeting_time')
|
||||
meeting_time_text = ''
|
||||
if isinstance(meeting_time, datetime):
|
||||
meeting_time_text = meeting_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
elif meeting_time:
|
||||
meeting_time_text = str(meeting_time)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT u.caption
|
||||
FROM attendees a
|
||||
JOIN sys_users u ON a.user_id = u.user_id
|
||||
WHERE a.meeting_id = %s
|
||||
ORDER BY u.caption ASC
|
||||
""",
|
||||
(meeting_id,)
|
||||
)
|
||||
attendee_rows = cursor.fetchall()
|
||||
attendee_names = [row.get('caption') for row in attendee_rows if row.get('caption')]
|
||||
attendee_text = '、'.join(attendee_names)
|
||||
cursor.close()
|
||||
|
||||
return {
|
||||
'title': meeting.get('title') or '',
|
||||
'meeting_time': meeting_time_text,
|
||||
'meeting_time_value': meeting_time,
|
||||
'creator_name': meeting.get('creator_name') or '',
|
||||
'attendees': attendee_text
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"获取会议提示词上下文错误: {e}")
|
||||
return {}
|
||||
|
||||
def _format_meeting_time_value(self, meeting_time_value: Any, custom_format: Optional[str] = None) -> str:
|
||||
default_format = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
if isinstance(meeting_time_value, datetime):
|
||||
try:
|
||||
return meeting_time_value.strftime(custom_format or default_format)
|
||||
except Exception:
|
||||
return meeting_time_value.strftime(default_format)
|
||||
|
||||
if meeting_time_value:
|
||||
meeting_time_text = str(meeting_time_value)
|
||||
if custom_format:
|
||||
try:
|
||||
parsed_time = datetime.fromisoformat(meeting_time_text.replace('Z', '+00:00'))
|
||||
return parsed_time.strftime(custom_format)
|
||||
except Exception:
|
||||
return meeting_time_text
|
||||
return meeting_time_text
|
||||
|
||||
return ''
|
||||
|
||||
def _apply_prompt_variables(self, template: str, variables: Dict[str, Any]) -> str:
|
||||
if not template:
|
||||
return template
|
||||
|
||||
rendered = re.sub(
|
||||
r"\{\{\s*meeting_time\s*:\s*([^{}]+?)\s*\}\}",
|
||||
lambda match: self._format_meeting_time_value(
|
||||
variables.get('meeting_time_value'),
|
||||
match.group(1).strip()
|
||||
),
|
||||
template
|
||||
)
|
||||
|
||||
for key, value in variables.items():
|
||||
if key == 'meeting_time_value':
|
||||
continue
|
||||
rendered = rendered.replace(f"{{{{{key}}}}}", value or '')
|
||||
rendered = rendered.replace(f"{{{{ {key} }}}}", value or '')
|
||||
return rendered
|
||||
|
||||
def _build_messages(self, meeting_id: int, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> List[Dict[str, str]]:
|
||||
"""
|
||||
构建完整的提示词
|
||||
使用数据库中配置的MEETING_TASK提示词模板
|
||||
构建会议总结消息数组
|
||||
使用数据库中配置的 MEETING_TASK 提示词模板作为任务级 system 指令
|
||||
|
||||
Args:
|
||||
meeting_id: 会议ID
|
||||
transcript_text: 会议转录文本
|
||||
user_prompt: 用户额外提示词
|
||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||
"""
|
||||
# 从数据库获取会议任务的提示词模板(支持指定prompt_id)
|
||||
system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
|
||||
task_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
|
||||
meeting_context = self._get_meeting_prompt_context(meeting_id)
|
||||
prompt_variables = {
|
||||
'meeting_id': str(meeting_id),
|
||||
'meeting_title': meeting_context.get('title', ''),
|
||||
'meeting_time': meeting_context.get('meeting_time', ''),
|
||||
'meeting_creator': meeting_context.get('creator_name', ''),
|
||||
'meeting_attendees': meeting_context.get('attendees', ''),
|
||||
'meeting_time_value': meeting_context.get('meeting_time_value')
|
||||
}
|
||||
rendered_task_prompt = self._apply_prompt_variables(task_prompt, prompt_variables)
|
||||
rendered_user_prompt = self._apply_prompt_variables(user_prompt, prompt_variables) if user_prompt else ''
|
||||
|
||||
prompt = f"{system_prompt}\n\n"
|
||||
meeting_info_lines = [
|
||||
f"会议ID:{prompt_variables['meeting_id']}",
|
||||
f"会议标题:{prompt_variables['meeting_title'] or '未提供'}",
|
||||
f"会议时间:{prompt_variables['meeting_time'] or '未提供'}",
|
||||
f"会议创建人:{prompt_variables['meeting_creator'] or '未提供'}",
|
||||
f"参会人员:{prompt_variables['meeting_attendees'] or '未提供'}",
|
||||
]
|
||||
meeting_info_message = "\n".join(meeting_info_lines)
|
||||
user_requirement_message = rendered_user_prompt or "无额外要求"
|
||||
|
||||
if user_prompt:
|
||||
prompt += f"用户额外要求:{user_prompt}\n\n"
|
||||
messages: List[Dict[str, str]] = []
|
||||
if rendered_task_prompt:
|
||||
messages.append({"role": "system", "content": rendered_task_prompt})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
"以下是本次会议的上下文信息,请结合这些信息理解会议背景。\n\n"
|
||||
f"{meeting_info_message}\n\n"
|
||||
"以下是用户额外要求,如与事实冲突请以转录原文为准:\n"
|
||||
f"{user_requirement_message}"
|
||||
)
|
||||
})
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
"以下是会议转录原文,请严格依据原文生成会议总结。\n"
|
||||
"如果信息不足,请明确写出“原文未明确”或“需人工确认”。\n\n"
|
||||
"<meeting_transcript>\n"
|
||||
f"{transcript_text}\n"
|
||||
"</meeting_transcript>"
|
||||
)
|
||||
})
|
||||
|
||||
prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:"
|
||||
return messages
|
||||
|
||||
return prompt
|
||||
|
||||
def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str, prompt_id: Optional[int] = None) -> Optional[int]:
|
||||
def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str, prompt_id: Optional[int] = None) -> int:
|
||||
"""保存总结到数据库 - 更新meetings表的summary、user_prompt、prompt_id和updated_at字段"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("SELECT 1 FROM meetings WHERE meeting_id = %s LIMIT 1", (meeting_id,))
|
||||
if not cursor.fetchone():
|
||||
raise RuntimeError(f"会议不存在,无法保存总结: meeting_id={meeting_id}")
|
||||
|
||||
# 更新meetings表的summary、user_prompt、prompt_id和updated_at字段
|
||||
update_query = """
|
||||
UPDATE meetings
|
||||
SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW()
|
||||
WHERE meeting_id = %s
|
||||
"""
|
||||
cursor.execute(update_query, (summary_content, user_prompt, prompt_id, meeting_id))
|
||||
connection.commit()
|
||||
update_query = """
|
||||
UPDATE meetings
|
||||
SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW()
|
||||
WHERE meeting_id = %s
|
||||
"""
|
||||
cursor.execute(update_query, (summary_content, user_prompt, prompt_id, meeting_id))
|
||||
connection.commit()
|
||||
|
||||
print(f"成功保存会议总结到meetings表,meeting_id: {meeting_id}, prompt_id: {prompt_id}")
|
||||
return meeting_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"保存总结到数据库错误: {e}")
|
||||
return None
|
||||
print(f"成功保存会议总结到meetings表,meeting_id: {meeting_id}, prompt_id: {prompt_id}")
|
||||
return meeting_id
|
||||
|
||||
# --- 状态查询和数据库操作方法 ---
|
||||
|
||||
|
|
@ -295,27 +537,63 @@ class AsyncMeetingService:
|
|||
task_data = self._get_task_from_db(task_id)
|
||||
if not task_data:
|
||||
return {'task_id': task_id, 'status': 'not_found', 'error_message': 'Task not found'}
|
||||
self._resume_task_if_needed(task_id, task_data)
|
||||
task_data = self.redis_client.hgetall(f"llm_task:{task_id}") or task_data
|
||||
|
||||
return {
|
||||
'task_id': task_id,
|
||||
'status': task_data.get('status', 'unknown'),
|
||||
'progress': int(task_data.get('progress', 0)),
|
||||
'meeting_id': int(task_data.get('meeting_id', 0)),
|
||||
'model_code': self._normalize_optional_text(task_data.get('model_code')),
|
||||
'created_at': task_data.get('created_at'),
|
||||
'updated_at': task_data.get('updated_at'),
|
||||
'result': task_data.get('result'),
|
||||
'error_message': task_data.get('error_message')
|
||||
'message': task_data.get('message'),
|
||||
'result': self._normalize_optional_text(task_data.get('result')),
|
||||
'error_message': self._normalize_optional_text(task_data.get('error_message'))
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting task status: {e}")
|
||||
return {'task_id': task_id, 'status': 'error', 'error_message': str(e)}
|
||||
|
||||
def _resume_task_if_needed(self, task_id: str, task_data: Dict[str, Any]) -> None:
|
||||
"""恢复服务重启后丢失在内存中的总结任务。"""
|
||||
try:
|
||||
status = str(task_data.get('status') or '').lower()
|
||||
if status not in {'pending', 'processing'}:
|
||||
return
|
||||
|
||||
restored_data = {
|
||||
'task_id': task_id,
|
||||
'meeting_id': str(task_data.get('meeting_id') or ''),
|
||||
'user_prompt': '' if task_data.get('user_prompt') in (None, 'None') else str(task_data.get('user_prompt')),
|
||||
'prompt_id': '' if task_data.get('prompt_id') in (None, 'None') else str(task_data.get('prompt_id')),
|
||||
'model_code': '' if task_data.get('model_code') in (None, 'None') else str(task_data.get('model_code')),
|
||||
'status': status,
|
||||
'progress': str(task_data.get('progress') or 0),
|
||||
'created_at': task_data.get('created_at') or datetime.now().isoformat(),
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'message': '任务恢复中,准备继续执行...',
|
||||
}
|
||||
self.redis_client.hset(f"llm_task:{task_id}", mapping=restored_data)
|
||||
self.redis_client.expire(f"llm_task:{task_id}", 86400)
|
||||
|
||||
submitted = summary_task_runner.submit(f"meeting-summary:{task_id}", self._process_task, task_id)
|
||||
print(f"[LLM Task Recovery] task_id={task_id}, status={status}, submitted={submitted}")
|
||||
except Exception as e:
|
||||
print(f"Error resuming summary task {task_id}: {e}")
|
||||
|
||||
def get_meeting_llm_tasks(self, meeting_id: int) -> List[Dict[str, Any]]:
|
||||
"""获取会议的所有LLM任务"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
query = "SELECT task_id, status, progress, user_prompt, created_at, completed_at, error_message FROM llm_tasks WHERE meeting_id = %s ORDER BY created_at DESC"
|
||||
query = """
|
||||
SELECT task_id, status, progress, user_prompt, model_code, result, created_at, completed_at, error_message
|
||||
FROM llm_tasks
|
||||
WHERE meeting_id = %s
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
cursor.execute(query, (meeting_id,))
|
||||
tasks = cursor.fetchall()
|
||||
for task in tasks:
|
||||
|
|
@ -342,7 +620,7 @@ class AsyncMeetingService:
|
|||
|
||||
# 查询最新的LLM任务
|
||||
query = """
|
||||
SELECT task_id, status, progress, created_at, completed_at, error_message
|
||||
SELECT task_id, status, progress, model_code, result, created_at, completed_at, error_message
|
||||
FROM llm_tasks
|
||||
WHERE meeting_id = %s
|
||||
ORDER BY created_at DESC
|
||||
|
|
@ -368,8 +646,10 @@ class AsyncMeetingService:
|
|||
'status': task_record['status'],
|
||||
'progress': task_record['progress'] or 0,
|
||||
'meeting_id': meeting_id,
|
||||
'model_code': task_record.get('model_code'),
|
||||
'created_at': task_record['created_at'].isoformat() if task_record['created_at'] else None,
|
||||
'completed_at': task_record['completed_at'].isoformat() if task_record['completed_at'] else None,
|
||||
'result': task_record.get('result'),
|
||||
'error_message': task_record['error_message']
|
||||
}
|
||||
|
||||
|
|
@ -380,19 +660,31 @@ class AsyncMeetingService:
|
|||
def _update_task_status_in_redis(self, task_id: str, status: str, progress: int, message: str = None, result: str = None, error_message: str = None):
|
||||
"""更新Redis中的任务状态"""
|
||||
try:
|
||||
redis_key = f"llm_task:{task_id}"
|
||||
update_data = {
|
||||
'status': status,
|
||||
'progress': str(progress),
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
if message: update_data['message'] = message
|
||||
if result: update_data['result'] = result
|
||||
if error_message: update_data['error_message'] = error_message
|
||||
self.redis_client.hset(f"llm_task:{task_id}", mapping=update_data)
|
||||
if result is not None: update_data['result'] = result
|
||||
if error_message is not None: update_data['error_message'] = error_message
|
||||
self.redis_client.hset(redis_key, mapping=update_data)
|
||||
if status == 'failed':
|
||||
self.redis_client.hdel(redis_key, 'result')
|
||||
elif status == 'completed':
|
||||
self.redis_client.hdel(redis_key, 'error_message')
|
||||
except Exception as e:
|
||||
print(f"Error updating task status in Redis: {e}")
|
||||
|
||||
def _save_task_to_db(self, task_id: str, meeting_id: int, user_prompt: str, prompt_id: Optional[int] = None):
|
||||
def _save_task_to_db(
|
||||
self,
|
||||
task_id: str,
|
||||
meeting_id: int,
|
||||
user_prompt: str,
|
||||
prompt_id: Optional[int] = None,
|
||||
model_code: Optional[str] = None
|
||||
):
|
||||
"""保存任务到数据库
|
||||
|
||||
Args:
|
||||
|
|
@ -400,12 +692,16 @@ class AsyncMeetingService:
|
|||
meeting_id: 会议ID
|
||||
user_prompt: 用户额外提示词
|
||||
prompt_id: 可选的提示词模版ID,如果为None则使用默认模版
|
||||
model_code: 可选的模型编码,用于恢复/重试时复用原始模型
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
insert_query = "INSERT INTO llm_tasks (task_id, meeting_id, user_prompt, prompt_id, status, progress, created_at) VALUES (%s, %s, %s, %s, 'pending', 0, NOW())"
|
||||
cursor.execute(insert_query, (task_id, meeting_id, user_prompt, prompt_id))
|
||||
insert_query = """
|
||||
INSERT INTO llm_tasks (task_id, meeting_id, user_prompt, prompt_id, model_code, status, progress, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, 'pending', 0, NOW())
|
||||
"""
|
||||
cursor.execute(insert_query, (task_id, meeting_id, user_prompt, prompt_id, model_code))
|
||||
connection.commit()
|
||||
print(f"[Meeting Service] Task saved successfully to database")
|
||||
except Exception as e:
|
||||
|
|
@ -414,20 +710,42 @@ class AsyncMeetingService:
|
|||
|
||||
def _update_task_in_db(self, task_id: str, status: str, progress: int, result: str = None, error_message: str = None):
|
||||
"""更新数据库中的任务状态"""
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
params = [status, progress, error_message, task_id]
|
||||
if status == 'completed':
|
||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s, result = %s, completed_at = NOW() WHERE task_id = %s"
|
||||
params.insert(2, result)
|
||||
else:
|
||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s WHERE task_id = %s"
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
if status == 'completed':
|
||||
query = """
|
||||
UPDATE llm_tasks
|
||||
SET status = %s, progress = %s, result = %s, error_message = NULL, completed_at = NOW()
|
||||
WHERE task_id = %s
|
||||
"""
|
||||
params = (status, progress, result, task_id)
|
||||
else:
|
||||
query = """
|
||||
UPDATE llm_tasks
|
||||
SET status = %s, progress = %s, result = NULL, error_message = %s, completed_at = NULL
|
||||
WHERE task_id = %s
|
||||
"""
|
||||
params = (status, progress, error_message, task_id)
|
||||
|
||||
cursor.execute(query, tuple(params))
|
||||
connection.commit()
|
||||
except Exception as e:
|
||||
print(f"Error updating task in database: {e}")
|
||||
cursor.execute(query, params)
|
||||
if cursor.rowcount == 0:
|
||||
raise RuntimeError(f"更新LLM任务状态失败,任务不存在: task_id={task_id}")
|
||||
connection.commit()
|
||||
|
||||
def _mark_task_failed(self, task_id: str, error_message: str) -> None:
|
||||
"""尽力持久化失败状态,避免原始异常被状态更新失败覆盖。"""
|
||||
try:
|
||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_message)
|
||||
except Exception as update_error:
|
||||
print(f"Error updating failed task in database: {update_error}")
|
||||
|
||||
self._update_task_status_in_redis(
|
||||
task_id,
|
||||
'failed',
|
||||
0,
|
||||
message="任务执行失败",
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
def _get_task_from_db(self, task_id: str) -> Optional[Dict[str, str]]:
|
||||
"""从数据库获取任务信息"""
|
||||
|
|
@ -438,13 +756,21 @@ class AsyncMeetingService:
|
|||
cursor.execute(query, (task_id,))
|
||||
task = cursor.fetchone()
|
||||
if task:
|
||||
# 确保所有字段都是字符串,以匹配Redis的行为
|
||||
return {k: v.isoformat() if isinstance(v, datetime) else str(v) for k, v in task.items()}
|
||||
return {
|
||||
key: value.isoformat() if isinstance(value, datetime) else (None if value is None else str(value))
|
||||
for key, value in task.items()
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error getting task from database: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_optional_text(value: Any) -> Optional[str]:
|
||||
if value in (None, "", "None"):
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
def _get_existing_summary_task(self, meeting_id: int) -> Optional[str]:
|
||||
"""
|
||||
检查会议是否已经有总结任务(用于并发控制)
|
||||
|
|
@ -471,5 +797,30 @@ class AsyncMeetingService:
|
|||
print(f"Error checking existing summary task: {e}")
|
||||
return None
|
||||
|
||||
def _acquire_lock(self, lock_key: str, ttl_seconds: int) -> Optional[str]:
|
||||
"""使用 Redis 分布式锁防止多 worker 重复执行同一后台任务。"""
|
||||
try:
|
||||
token = str(uuid.uuid4())
|
||||
acquired = self.redis_client.set(lock_key, token, nx=True, ex=max(30, ttl_seconds))
|
||||
if acquired:
|
||||
return token
|
||||
except Exception as e:
|
||||
print(f"Error acquiring lock {lock_key}: {e}")
|
||||
return None
|
||||
|
||||
def _release_lock(self, lock_key: str, token: Optional[str]) -> None:
|
||||
if not token:
|
||||
return
|
||||
try:
|
||||
release_script = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
self.redis_client.eval(release_script, 1, lock_key, token)
|
||||
except Exception as e:
|
||||
print(f"Error releasing lock {lock_key}: {e}")
|
||||
|
||||
# 创建全局实例
|
||||
async_meeting_service = AsyncMeetingService()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import uuid
|
||||
import json
|
||||
import os
|
||||
import redis
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
|
@ -9,20 +9,153 @@ from http import HTTPStatus
|
|||
import dashscope
|
||||
from dashscope.audio.asr import Transcription
|
||||
|
||||
from app.core.config import QWEN_API_KEY, REDIS_CONFIG, APP_CONFIG
|
||||
from app.core.config import REDIS_CONFIG, APP_CONFIG, BACKGROUND_TASK_CONFIG
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
|
||||
|
||||
class _DefaultTimeoutSession(requests.Session):
|
||||
"""为 requests.Session 注入默认超时。"""
|
||||
|
||||
def __init__(self, default_timeout: Optional[int] = None):
|
||||
super().__init__()
|
||||
self.default_timeout = default_timeout
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
if "timeout" not in kwargs and self.default_timeout:
|
||||
kwargs["timeout"] = self.default_timeout
|
||||
return super().request(method, url, **kwargs)
|
||||
|
||||
|
||||
class AsyncTranscriptionService:
|
||||
"""异步转录服务类"""
|
||||
|
||||
PREPROCESS_COMPLETED_PROGRESS = 50
|
||||
TRANSCRIPTION_COMPLETED_PROGRESS = 100
|
||||
|
||||
def __init__(self):
|
||||
dashscope.api_key = QWEN_API_KEY
|
||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
self.base_url = APP_CONFIG['base_url']
|
||||
self.base_url = APP_CONFIG['base_url'].rstrip('/')
|
||||
|
||||
@staticmethod
|
||||
def _create_requests_session(default_timeout: Optional[int] = None) -> requests.Session:
|
||||
session = _DefaultTimeoutSession(default_timeout=default_timeout)
|
||||
session.trust_env = os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"}
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _normalize_dashscope_base_address(endpoint_url: Optional[str]) -> Optional[str]:
|
||||
if not endpoint_url:
|
||||
return None
|
||||
normalized = str(endpoint_url).strip().rstrip("/")
|
||||
suffix = "/services/audio/asr/transcription"
|
||||
if normalized.endswith(suffix):
|
||||
normalized = normalized[: -len(suffix)]
|
||||
return normalized or None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_dashscope_api_key(audio_config: Optional[Dict[str, Any]] = None) -> str:
|
||||
api_key = (audio_config or {}).get("api_key")
|
||||
if isinstance(api_key, str):
|
||||
api_key = api_key.strip()
|
||||
if not api_key:
|
||||
raise Exception("未在启用的 ASR 模型配置中设置 DashScope API Key")
|
||||
return api_key
|
||||
|
||||
def _build_dashscope_request_options(self, audio_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
request_options: Dict[str, Any] = {
|
||||
"api_key": self._resolve_dashscope_api_key(audio_config),
|
||||
}
|
||||
endpoint_url = (audio_config or {}).get("endpoint_url")
|
||||
base_address = self._normalize_dashscope_base_address(endpoint_url)
|
||||
if base_address:
|
||||
request_options["base_address"] = base_address
|
||||
return request_options
|
||||
|
||||
@staticmethod
|
||||
def _resolve_request_timeout_seconds(audio_config: Optional[Dict[str, Any]] = None) -> int:
|
||||
value = (audio_config or {}).get("request_timeout_seconds")
|
||||
try:
|
||||
timeout_seconds = int(value)
|
||||
except (TypeError, ValueError):
|
||||
timeout_seconds = 300
|
||||
return max(10, timeout_seconds)
|
||||
|
||||
def _dashscope_async_call(self, request_options: Dict[str, Any], call_params: Dict[str, Any], timeout_seconds: int):
|
||||
session = self._create_requests_session(timeout_seconds)
|
||||
try:
|
||||
try:
|
||||
return Transcription.async_call(session=session, **request_options, **call_params)
|
||||
except TypeError:
|
||||
return Transcription.async_call(**request_options, **call_params)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _dashscope_fetch(self, paraformer_task_id: str, request_options: Dict[str, Any], timeout_seconds: int):
|
||||
session = self._create_requests_session(timeout_seconds)
|
||||
try:
|
||||
try:
|
||||
return Transcription.fetch(task=paraformer_task_id, session=session, **request_options)
|
||||
except TypeError:
|
||||
return Transcription.fetch(task=paraformer_task_id, **request_options)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@staticmethod
|
||||
def _build_dashscope_call_params(audio_config: Dict[str, Any], file_url: str) -> Dict[str, Any]:
|
||||
model_name = audio_config.get("model") or "paraformer-v2"
|
||||
call_params: Dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"file_urls": [file_url],
|
||||
}
|
||||
optional_keys = [
|
||||
"language_hints",
|
||||
"disfluency_removal_enabled",
|
||||
"diarization_enabled",
|
||||
"speaker_count",
|
||||
"vocabulary_id",
|
||||
"timestamp_alignment_enabled",
|
||||
"channel_id",
|
||||
"special_word_filter",
|
||||
"audio_event_detection_enabled",
|
||||
"phrase_id",
|
||||
]
|
||||
for key in optional_keys:
|
||||
value = audio_config.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
if isinstance(value, list) and not value:
|
||||
continue
|
||||
call_params[key] = value
|
||||
return call_params
|
||||
|
||||
def test_asr_model(self, audio_config: Dict[str, Any], test_file_url: Optional[str] = None) -> Dict[str, Any]:
|
||||
provider = str(audio_config.get("provider") or "dashscope").strip().lower()
|
||||
if provider != "dashscope":
|
||||
raise Exception(f"当前仅支持 DashScope 音频识别测试,暂不支持供应商: {provider}")
|
||||
|
||||
request_options = self._build_dashscope_request_options(audio_config)
|
||||
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
|
||||
dashscope.api_key = request_options["api_key"]
|
||||
target_file_url = (
|
||||
test_file_url
|
||||
or "https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav"
|
||||
)
|
||||
call_params = self._build_dashscope_call_params(audio_config, target_file_url)
|
||||
response = self._dashscope_async_call(request_options, call_params, timeout_seconds)
|
||||
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
raise Exception(response.message or "音频模型测试失败")
|
||||
|
||||
return {
|
||||
"provider_task_id": response.output.task_id,
|
||||
"test_file_url": target_file_url,
|
||||
"used_params": call_params,
|
||||
}
|
||||
|
||||
def start_transcription(self, meeting_id: int, audio_file_path: str) -> str:
|
||||
def start_transcription(self, meeting_id: int, audio_file_path: str, business_task_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
启动异步转录任务
|
||||
|
||||
|
|
@ -44,8 +177,14 @@ class AsyncTranscriptionService:
|
|||
deleted_segments = cursor.rowcount
|
||||
print(f"Deleted {deleted_segments} old transcript segments")
|
||||
|
||||
# 删除旧的转录任务记录
|
||||
cursor.execute("DELETE FROM transcript_tasks WHERE meeting_id = %s", (meeting_id,))
|
||||
# 删除旧的转录任务记录;如果已创建本地占位任务,则保留当前任务记录
|
||||
if business_task_id:
|
||||
cursor.execute(
|
||||
"DELETE FROM transcript_tasks WHERE meeting_id = %s AND task_id <> %s",
|
||||
(meeting_id, business_task_id),
|
||||
)
|
||||
else:
|
||||
cursor.execute("DELETE FROM transcript_tasks WHERE meeting_id = %s", (meeting_id,))
|
||||
deleted_tasks = cursor.rowcount
|
||||
print(f"Deleted {deleted_tasks} old transcript tasks")
|
||||
|
||||
|
|
@ -57,60 +196,117 @@ class AsyncTranscriptionService:
|
|||
cursor.close()
|
||||
|
||||
# 2. 构造完整的文件URL
|
||||
file_url = f"{self.base_url}{audio_file_path}"
|
||||
file_url = f"{self.base_url}/{audio_file_path.lstrip('/')}"
|
||||
|
||||
# 获取热词表ID (asr_vocabulary_id)
|
||||
vocabulary_id = SystemConfigService.get_asr_vocabulary_id()
|
||||
# 获取音频模型配置
|
||||
audio_config = SystemConfigService.get_active_audio_model_config("asr")
|
||||
provider = str(audio_config.get("provider") or "dashscope").strip().lower()
|
||||
if provider != "dashscope":
|
||||
raise Exception(f"当前仅支持 DashScope 音频识别,暂不支持供应商: {provider}")
|
||||
|
||||
print(f"Starting transcription for meeting_id: {meeting_id}, file_url: {file_url}, vocabulary_id: {vocabulary_id}")
|
||||
request_options = self._build_dashscope_request_options(audio_config)
|
||||
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
|
||||
dashscope.api_key = request_options["api_key"]
|
||||
call_params = self._build_dashscope_call_params(audio_config, file_url)
|
||||
|
||||
print(
|
||||
f"Starting transcription for meeting_id: {meeting_id}, "
|
||||
f"file_url: {file_url}, model: {call_params.get('model')}, "
|
||||
f"vocabulary_id: {call_params.get('vocabulary_id')}"
|
||||
)
|
||||
|
||||
# 3. 调用Paraformer异步API
|
||||
call_params = {
|
||||
'model': 'paraformer-v2',
|
||||
'file_urls': [file_url],
|
||||
'language_hints': ['zh', 'en'],
|
||||
'disfluency_removal_enabled': True,
|
||||
'diarization_enabled': True,
|
||||
'speaker_count': 10
|
||||
}
|
||||
if vocabulary_id:
|
||||
call_params['vocabulary_id'] = vocabulary_id
|
||||
|
||||
task_response = Transcription.async_call(**call_params)
|
||||
task_response = self._dashscope_async_call(request_options, call_params, timeout_seconds)
|
||||
|
||||
if task_response.status_code != HTTPStatus.OK:
|
||||
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
|
||||
raise Exception(f"Transcription API error: {task_response.message}")
|
||||
|
||||
paraformer_task_id = task_response.output.task_id
|
||||
business_task_id = str(uuid.uuid4())
|
||||
final_business_task_id = business_task_id or str(uuid.uuid4())
|
||||
|
||||
# 4. 在Redis中存储任务映射
|
||||
current_time = datetime.now().isoformat()
|
||||
task_data = {
|
||||
'business_task_id': business_task_id,
|
||||
'business_task_id': final_business_task_id,
|
||||
'paraformer_task_id': paraformer_task_id,
|
||||
'meeting_id': str(meeting_id),
|
||||
'file_url': file_url,
|
||||
'status': 'pending',
|
||||
'progress': '0',
|
||||
'progress': str(self.PREPROCESS_COMPLETED_PROGRESS),
|
||||
'created_at': current_time,
|
||||
'updated_at': current_time
|
||||
}
|
||||
|
||||
# 存储到Redis,过期时间24小时
|
||||
self.redis_client.hset(f"task:{business_task_id}", mapping=task_data)
|
||||
self.redis_client.expire(f"task:{business_task_id}", 86400)
|
||||
self.redis_client.hset(f"task:{final_business_task_id}", mapping=task_data)
|
||||
self.redis_client.expire(f"task:{final_business_task_id}", 86400)
|
||||
|
||||
# 5. 在数据库中创建任务记录
|
||||
self._save_task_to_db(business_task_id, paraformer_task_id, meeting_id, audio_file_path)
|
||||
self._save_task_to_db(final_business_task_id, paraformer_task_id, meeting_id, audio_file_path)
|
||||
|
||||
print(f"Transcription task created: {business_task_id}")
|
||||
return business_task_id
|
||||
print(f"Transcription task created: {final_business_task_id}")
|
||||
return final_business_task_id
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error starting transcription: {e}")
|
||||
raise e
|
||||
|
||||
def create_local_processing_task(
|
||||
self,
|
||||
meeting_id: int,
|
||||
status: str = "processing",
|
||||
progress: int = 0,
|
||||
error_message: Optional[str] = None,
|
||||
) -> str:
|
||||
business_task_id = str(uuid.uuid4())
|
||||
current_time = datetime.now().isoformat()
|
||||
task_data = {
|
||||
"business_task_id": business_task_id,
|
||||
"paraformer_task_id": "",
|
||||
"meeting_id": str(meeting_id),
|
||||
"status": status,
|
||||
"progress": str(progress),
|
||||
"created_at": current_time,
|
||||
"updated_at": current_time,
|
||||
"error_message": error_message or "",
|
||||
}
|
||||
self.redis_client.hset(f"task:{business_task_id}", mapping=task_data)
|
||||
self.redis_client.expire(f"task:{business_task_id}", 86400)
|
||||
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at, error_message)
|
||||
VALUES (%s, NULL, %s, %s, %s, NOW(), %s)
|
||||
""",
|
||||
(business_task_id, meeting_id, status, progress, error_message),
|
||||
)
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
|
||||
return business_task_id
|
||||
|
||||
def update_local_processing_task(
|
||||
self,
|
||||
business_task_id: str,
|
||||
status: str,
|
||||
progress: int,
|
||||
error_message: Optional[str] = None,
|
||||
) -> None:
|
||||
updated_at = datetime.now().isoformat()
|
||||
self.redis_client.hset(
|
||||
f"task:{business_task_id}",
|
||||
mapping={
|
||||
"status": status,
|
||||
"progress": str(progress),
|
||||
"updated_at": updated_at,
|
||||
"error_message": error_message or "",
|
||||
},
|
||||
)
|
||||
self.redis_client.expire(f"task:{business_task_id}", 86400)
|
||||
self._update_task_status_in_db(business_task_id, status, progress, error_message)
|
||||
|
||||
def get_task_status(self, business_task_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -126,52 +322,84 @@ class AsyncTranscriptionService:
|
|||
current_status = 'failed'
|
||||
progress = 0
|
||||
error_message = "An unknown error occurred."
|
||||
updated_at = datetime.now().isoformat()
|
||||
status_cache_key = f"task_status_cache:{business_task_id}"
|
||||
|
||||
try:
|
||||
# 1. 获取任务数据(优先Redis,回源DB)
|
||||
task_data = self._get_task_data(business_task_id)
|
||||
paraformer_task_id = task_data['paraformer_task_id']
|
||||
|
||||
# 2. 查询外部API获取状态
|
||||
try:
|
||||
paraformer_response = Transcription.fetch(task=paraformer_task_id)
|
||||
if paraformer_response.status_code != HTTPStatus.OK:
|
||||
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
|
||||
|
||||
paraformer_status = paraformer_response.output.task_status
|
||||
current_status = self._map_paraformer_status(paraformer_status)
|
||||
progress = self._calculate_progress(paraformer_status)
|
||||
error_message = None #执行成功,清除初始状态
|
||||
|
||||
except Exception as e:
|
||||
current_status = 'failed'
|
||||
progress = 0
|
||||
error_message = f"Error fetching status from provider: {e}"
|
||||
# 直接进入finally块更新状态后返回
|
||||
return
|
||||
|
||||
# 3. 如果任务完成,处理结果
|
||||
if current_status == 'completed' and paraformer_response.output.get('results'):
|
||||
# 防止并发处理:先检查数据库中的状态
|
||||
db_task_status = self._get_task_status_from_db(business_task_id)
|
||||
if db_task_status != 'completed':
|
||||
# 只有当数据库中状态不是completed时才处理
|
||||
# 先将状态更新为completed,作为分布式锁
|
||||
self._update_task_status_in_db(business_task_id, 'completed', 100, None)
|
||||
|
||||
try:
|
||||
self._process_transcription_result(
|
||||
business_task_id,
|
||||
int(task_data['meeting_id']),
|
||||
paraformer_response.output
|
||||
)
|
||||
except Exception as e:
|
||||
current_status = 'failed'
|
||||
progress = 100 # 进度为100,但状态是失败
|
||||
error_message = f"Error processing transcription result: {e}"
|
||||
print(error_message)
|
||||
stored_status = str(task_data.get('status') or '').lower()
|
||||
if stored_status in {'completed', 'failed'}:
|
||||
current_status = stored_status
|
||||
progress = int(task_data.get('progress') or 0)
|
||||
error_message = task_data.get('error_message') or None
|
||||
updated_at = task_data.get('updated_at') or updated_at
|
||||
else:
|
||||
paraformer_task_id = task_data.get('paraformer_task_id')
|
||||
if not paraformer_task_id:
|
||||
current_status = task_data.get('status') or 'processing'
|
||||
progress = int(task_data.get('progress') or 0)
|
||||
error_message = task_data.get('error_message') or None
|
||||
updated_at = task_data.get('updated_at') or updated_at
|
||||
else:
|
||||
print(f"Task {business_task_id} already processed, skipping duplicate processing")
|
||||
cached_status_raw = self.redis_client.hgetall(status_cache_key)
|
||||
cached_status = self._normalize_redis_mapping(cached_status_raw) if cached_status_raw else {}
|
||||
if cached_status and cached_status.get('status') in {'pending', 'processing'}:
|
||||
current_status = cached_status.get('status', 'pending')
|
||||
progress = int(cached_status.get('progress') or 0)
|
||||
error_message = cached_status.get('error_message') or None
|
||||
updated_at = cached_status.get('updated_at') or updated_at
|
||||
else:
|
||||
# 2. 查询外部API获取状态
|
||||
paraformer_response = None
|
||||
try:
|
||||
audio_config = SystemConfigService.get_active_audio_model_config("asr")
|
||||
request_options = self._build_dashscope_request_options(audio_config)
|
||||
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
|
||||
dashscope.api_key = request_options["api_key"]
|
||||
paraformer_response = self._dashscope_fetch(paraformer_task_id, request_options, timeout_seconds)
|
||||
if paraformer_response.status_code != HTTPStatus.OK:
|
||||
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
|
||||
|
||||
paraformer_status = paraformer_response.output.task_status
|
||||
current_status = self._map_paraformer_status(paraformer_status)
|
||||
progress = self._calculate_progress(paraformer_status)
|
||||
error_message = None
|
||||
|
||||
except Exception as e:
|
||||
# 云侧状态查询抖动不应直接把任务打成 failed,
|
||||
# 保持当前非终态并等待下一轮轮询重试。
|
||||
current_status = task_data.get('status') or 'processing'
|
||||
progress = int(task_data.get('progress') or 0)
|
||||
error_message = None
|
||||
print(
|
||||
f"Transient provider status fetch error for task {business_task_id}: {e}. "
|
||||
f"Keeping status={current_status}, progress={progress}"
|
||||
)
|
||||
|
||||
# 3. 如果任务完成,处理结果
|
||||
if (
|
||||
current_status == 'completed'
|
||||
and paraformer_response is not None
|
||||
and paraformer_response.output.get('results')
|
||||
):
|
||||
db_task_status = self._get_task_status_from_db(business_task_id)
|
||||
if db_task_status != 'completed':
|
||||
self._update_task_status_in_db(business_task_id, 'completed', 100, None)
|
||||
|
||||
try:
|
||||
self._process_transcription_result(
|
||||
business_task_id,
|
||||
int(task_data['meeting_id']),
|
||||
paraformer_response.output
|
||||
)
|
||||
except Exception as e:
|
||||
current_status = 'failed'
|
||||
progress = 100
|
||||
error_message = f"Error processing transcription result: {e}"
|
||||
print(error_message)
|
||||
else:
|
||||
print(f"Task {business_task_id} already processed, skipping duplicate processing")
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error getting task status: {e}"
|
||||
|
|
@ -192,37 +420,44 @@ class AsyncTranscriptionService:
|
|||
if error_message:
|
||||
update_data['error_message'] = error_message
|
||||
self.redis_client.hset(f"task:{business_task_id}", mapping=update_data)
|
||||
self.redis_client.hset(status_cache_key, mapping=update_data)
|
||||
self.redis_client.expire(
|
||||
status_cache_key,
|
||||
max(1, int(BACKGROUND_TASK_CONFIG.get('transcription_status_cache_ttl', 3)))
|
||||
)
|
||||
|
||||
# 更新数据库
|
||||
self._update_task_status_in_db(business_task_id, current_status, progress, error_message)
|
||||
|
||||
# 5. 构造并返回最终结果
|
||||
result = {
|
||||
'task_id': business_task_id,
|
||||
'status': current_status,
|
||||
'progress': progress,
|
||||
'error_message': error_message,
|
||||
'updated_at': updated_at,
|
||||
'meeting_id': None,
|
||||
'created_at': None,
|
||||
}
|
||||
if task_data:
|
||||
result['meeting_id'] = int(task_data['meeting_id'])
|
||||
result['created_at'] = task_data.get('created_at')
|
||||
|
||||
return result
|
||||
return self._build_task_status_result(
|
||||
business_task_id,
|
||||
task_data,
|
||||
current_status,
|
||||
progress,
|
||||
error_message,
|
||||
updated_at,
|
||||
)
|
||||
|
||||
def _get_task_data(self, business_task_id: str) -> Dict[str, Any]:
|
||||
"""从Redis或数据库获取任务数据"""
|
||||
# 尝试从Redis获取
|
||||
task_data_bytes = self.redis_client.hgetall(f"task:{business_task_id}")
|
||||
if task_data_bytes and task_data_bytes.get(b'paraformer_task_id'):
|
||||
# Redis返回的是bytes,需要解码
|
||||
return {k.decode('utf-8'): v.decode('utf-8') for k, v in task_data_bytes.items()}
|
||||
task_data_raw = self.redis_client.hgetall(f"task:{business_task_id}")
|
||||
if task_data_raw:
|
||||
task_data = self._normalize_redis_mapping(task_data_raw)
|
||||
if task_data.get('paraformer_task_id'):
|
||||
return task_data
|
||||
if str(task_data.get('status') or '').lower() in {'pending', 'processing', 'completed', 'failed'}:
|
||||
return task_data
|
||||
|
||||
# 如果Redis没有,从数据库回源
|
||||
task_data_from_db = self._get_task_from_db(business_task_id)
|
||||
if not task_data_from_db or not task_data_from_db.get('paraformer_task_id'):
|
||||
if not task_data_from_db:
|
||||
raise Exception("Task not found in DB")
|
||||
if (
|
||||
not task_data_from_db.get('paraformer_task_id')
|
||||
and str(task_data_from_db.get('status') or '').lower() not in {'pending', 'processing', 'completed', 'failed'}
|
||||
):
|
||||
raise Exception("Task not found in DB or paraformer_task_id is missing")
|
||||
|
||||
# 将从DB获取的数据缓存回Redis
|
||||
|
|
@ -296,9 +531,10 @@ class AsyncTranscriptionService:
|
|||
def _calculate_progress(self, paraformer_status: str) -> int:
|
||||
"""根据Paraformer状态计算进度"""
|
||||
progress_mapping = {
|
||||
'PENDING': 10,
|
||||
'RUNNING': 50,
|
||||
'SUCCEEDED': 100,
|
||||
# 预处理完成后,转录任务进入云侧排队/执行阶段,统一展示为 50。
|
||||
'PENDING': self.PREPROCESS_COMPLETED_PROGRESS,
|
||||
'RUNNING': self.PREPROCESS_COMPLETED_PROGRESS,
|
||||
'SUCCEEDED': self.TRANSCRIPTION_COMPLETED_PROGRESS,
|
||||
'FAILED': 0
|
||||
}
|
||||
return progress_mapping.get(paraformer_status, 0)
|
||||
|
|
@ -308,13 +544,38 @@ class AsyncTranscriptionService:
|
|||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor()
|
||||
|
||||
# 插入转录任务记录
|
||||
insert_task_query = """
|
||||
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at)
|
||||
VALUES (%s, %s, %s, 'pending', 0, NOW())
|
||||
"""
|
||||
cursor.execute(insert_task_query, (business_task_id, paraformer_task_id, meeting_id))
|
||||
|
||||
cursor.execute("SELECT task_id FROM transcript_tasks WHERE task_id = %s", (business_task_id,))
|
||||
existing = cursor.fetchone()
|
||||
if existing:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE transcript_tasks
|
||||
SET paraformer_task_id = %s, meeting_id = %s, status = 'pending', progress = %s,
|
||||
completed_at = NULL, error_message = NULL
|
||||
WHERE task_id = %s
|
||||
""",
|
||||
(
|
||||
paraformer_task_id,
|
||||
meeting_id,
|
||||
self.PREPROCESS_COMPLETED_PROGRESS,
|
||||
business_task_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
insert_task_query = """
|
||||
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at)
|
||||
VALUES (%s, %s, %s, 'pending', %s, NOW())
|
||||
"""
|
||||
cursor.execute(
|
||||
insert_task_query,
|
||||
(
|
||||
business_task_id,
|
||||
paraformer_task_id,
|
||||
meeting_id,
|
||||
self.PREPROCESS_COMPLETED_PROGRESS,
|
||||
),
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
cursor.close()
|
||||
|
|
@ -376,7 +637,8 @@ class AsyncTranscriptionService:
|
|||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
query = """
|
||||
SELECT tt.task_id as business_task_id, tt.paraformer_task_id, tt.meeting_id, tt.status, tt.created_at
|
||||
SELECT tt.task_id as business_task_id, tt.paraformer_task_id, tt.meeting_id, tt.status, tt.progress,
|
||||
tt.error_message, tt.created_at, tt.completed_at
|
||||
FROM transcript_tasks tt
|
||||
WHERE tt.task_id = %s
|
||||
"""
|
||||
|
|
@ -391,13 +653,55 @@ class AsyncTranscriptionService:
|
|||
'paraformer_task_id': result['paraformer_task_id'],
|
||||
'meeting_id': str(result['meeting_id']),
|
||||
'status': result['status'],
|
||||
'created_at': result['created_at'].isoformat() if result['created_at'] else None
|
||||
'progress': str(result.get('progress') or 0),
|
||||
'error_message': result.get('error_message') or '',
|
||||
'created_at': result['created_at'].isoformat() if result['created_at'] else None,
|
||||
'updated_at': (
|
||||
result['completed_at'].isoformat()
|
||||
if result.get('completed_at')
|
||||
else result['created_at'].isoformat() if result.get('created_at') else None
|
||||
),
|
||||
}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting task from database: {e}")
|
||||
return None
|
||||
|
||||
def _normalize_redis_mapping(self, mapping: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = {}
|
||||
for key, value in mapping.items():
|
||||
normalized_key = key.decode('utf-8') if isinstance(key, bytes) else str(key)
|
||||
if isinstance(value, bytes):
|
||||
normalized_value = value.decode('utf-8')
|
||||
else:
|
||||
normalized_value = value
|
||||
normalized[normalized_key] = normalized_value
|
||||
return normalized
|
||||
|
||||
def _build_task_status_result(
|
||||
self,
|
||||
business_task_id: str,
|
||||
task_data: Optional[Dict[str, Any]],
|
||||
status: str,
|
||||
progress: int,
|
||||
error_message: Optional[str],
|
||||
updated_at: str
|
||||
) -> Dict[str, Any]:
|
||||
result = {
|
||||
'task_id': business_task_id,
|
||||
'status': status,
|
||||
'progress': progress,
|
||||
'error_message': error_message,
|
||||
'updated_at': updated_at,
|
||||
'meeting_id': None,
|
||||
'created_at': None,
|
||||
}
|
||||
if task_data:
|
||||
meeting_id = task_data.get('meeting_id')
|
||||
result['meeting_id'] = int(meeting_id) if meeting_id is not None else None
|
||||
result['created_at'] = task_data.get('created_at')
|
||||
return result
|
||||
|
||||
def _process_transcription_result(self, business_task_id: str, meeting_id: int, paraformer_output: Any):
|
||||
"""
|
||||
|
|
@ -410,8 +714,14 @@ class AsyncTranscriptionService:
|
|||
|
||||
transcription_url = paraformer_output['results'][0]['transcription_url']
|
||||
print(f"Fetching transcription from URL: {transcription_url}")
|
||||
|
||||
response = requests.get(transcription_url)
|
||||
|
||||
audio_config = SystemConfigService.get_active_audio_model_config("asr")
|
||||
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
|
||||
session = self._create_requests_session(timeout_seconds)
|
||||
try:
|
||||
response = session.get(transcription_url, timeout=timeout_seconds)
|
||||
finally:
|
||||
session.close()
|
||||
response.raise_for_status()
|
||||
transcription_data = response.json()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
"""
|
||||
音频预处理服务
|
||||
|
||||
使用 ffprobe/ffmpeg 对上传音频做统一探测和规范化,降低长会议音频的格式兼容风险。
|
||||
当前阶段只做单文件预处理,不做拆片。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from app.utils.audio_parser import get_audio_duration
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioMetadata:
|
||||
"""音频元数据"""
|
||||
|
||||
duration_seconds: int = 0
|
||||
sample_rate: Optional[int] = None
|
||||
channels: Optional[int] = None
|
||||
codec_name: Optional[str] = None
|
||||
format_name: Optional[str] = None
|
||||
bit_rate: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioPreprocessResult:
|
||||
"""音频预处理结果"""
|
||||
|
||||
file_path: Path
|
||||
file_name: str
|
||||
file_size: int
|
||||
metadata: AudioMetadata
|
||||
applied: bool = False
|
||||
output_format: Optional[str] = None
|
||||
|
||||
|
||||
class AudioPreprocessService:
|
||||
"""基于 ffmpeg 的音频预处理服务"""
|
||||
|
||||
TARGET_EXTENSION = ".m4a"
|
||||
TARGET_SAMPLE_RATE = 16000
|
||||
TARGET_CHANNELS = 1
|
||||
TARGET_BITRATE = "64k"
|
||||
|
||||
def __init__(self):
|
||||
self.ffmpeg_path = shutil.which("ffmpeg")
|
||||
self.ffprobe_path = shutil.which("ffprobe")
|
||||
|
||||
def probe_audio(self, file_path: str | Path) -> AudioMetadata:
|
||||
"""
|
||||
使用 ffprobe 探测音频元数据。
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"音频文件不存在: {path}")
|
||||
|
||||
if self.ffprobe_path:
|
||||
metadata = self._probe_with_ffprobe(path)
|
||||
if metadata:
|
||||
return metadata
|
||||
|
||||
return AudioMetadata(duration_seconds=get_audio_duration(str(path)))
|
||||
|
||||
def preprocess(self, file_path: str | Path) -> AudioPreprocessResult:
|
||||
"""
|
||||
预处理音频为统一格式。
|
||||
|
||||
当前策略:
|
||||
1. 去除视频流,仅保留音频
|
||||
2. 统一单声道
|
||||
3. 统一采样率 16k
|
||||
4. 转为 m4a(aac)
|
||||
"""
|
||||
source_path = Path(file_path)
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"音频文件不存在: {source_path}")
|
||||
|
||||
if not self.ffmpeg_path:
|
||||
metadata = self.probe_audio(source_path)
|
||||
return AudioPreprocessResult(
|
||||
file_path=source_path,
|
||||
file_name=source_path.name,
|
||||
file_size=source_path.stat().st_size,
|
||||
metadata=metadata,
|
||||
applied=False,
|
||||
output_format=source_path.suffix.lower().lstrip(".") or None,
|
||||
)
|
||||
|
||||
output_path = source_path.with_name(f"{source_path.stem}_normalized{self.TARGET_EXTENSION}")
|
||||
temp_output_path = output_path.with_name(f"{output_path.stem}.tmp{output_path.suffix}")
|
||||
|
||||
command = [
|
||||
self.ffmpeg_path,
|
||||
"-y",
|
||||
"-i",
|
||||
str(source_path),
|
||||
"-vn",
|
||||
"-ac",
|
||||
str(self.TARGET_CHANNELS),
|
||||
"-ar",
|
||||
str(self.TARGET_SAMPLE_RATE),
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
self.TARGET_BITRATE,
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(temp_output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
stderr = (completed.stderr or "").strip()
|
||||
raise RuntimeError(stderr or "ffmpeg 预处理失败")
|
||||
|
||||
temp_output_path.replace(output_path)
|
||||
metadata = self.probe_audio(output_path)
|
||||
return AudioPreprocessResult(
|
||||
file_path=output_path,
|
||||
file_name=output_path.name,
|
||||
file_size=output_path.stat().st_size,
|
||||
metadata=metadata,
|
||||
applied=True,
|
||||
output_format=output_path.suffix.lower().lstrip("."),
|
||||
)
|
||||
finally:
|
||||
if temp_output_path.exists():
|
||||
temp_output_path.unlink()
|
||||
|
||||
def _probe_with_ffprobe(self, file_path: Path) -> Optional[AudioMetadata]:
|
||||
command = [
|
||||
self.ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
str(file_path),
|
||||
]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode != 0 or not completed.stdout:
|
||||
return None
|
||||
|
||||
payload = json.loads(completed.stdout)
|
||||
streams = payload.get("streams") or []
|
||||
audio_stream = next((stream for stream in streams if stream.get("codec_type") == "audio"), {})
|
||||
format_info = payload.get("format") or {}
|
||||
|
||||
duration_value = audio_stream.get("duration") or format_info.get("duration")
|
||||
duration_seconds = int(float(duration_value)) if duration_value else 0
|
||||
|
||||
sample_rate_value = audio_stream.get("sample_rate")
|
||||
channels_value = audio_stream.get("channels")
|
||||
bit_rate_value = audio_stream.get("bit_rate") or format_info.get("bit_rate")
|
||||
|
||||
return AudioMetadata(
|
||||
duration_seconds=duration_seconds,
|
||||
sample_rate=int(sample_rate_value) if sample_rate_value else None,
|
||||
channels=int(channels_value) if channels_value else None,
|
||||
codec_name=audio_stream.get("codec_name"),
|
||||
format_name=format_info.get("format_name"),
|
||||
bit_rate=int(bit_rate_value) if bit_rate_value else None,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
audio_preprocess_service = AudioPreprocessService()
|
||||
|
|
@ -25,7 +25,9 @@ def handle_audio_upload(
|
|||
auto_summarize: bool = True,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
prompt_id: int = None,
|
||||
duration: int = 0
|
||||
model_code: str = None,
|
||||
duration: int = 0,
|
||||
transcription_task_id: str = None,
|
||||
) -> dict:
|
||||
"""
|
||||
处理已保存的完整音频文件
|
||||
|
|
@ -44,9 +46,11 @@ def handle_audio_upload(
|
|||
meeting_id: 会议ID
|
||||
current_user: 当前用户信息
|
||||
auto_summarize: 是否自动生成总结(默认True)
|
||||
background_tasks: FastAPI 后台任务对象
|
||||
background_tasks: 为兼容现有调用保留,实际后台执行由服务层线程池完成
|
||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||
duration: 音频时长(秒)
|
||||
transcription_task_id: 预先创建的本地任务ID(可选,用于异步上传场景)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
|
|
@ -58,7 +62,7 @@ def handle_audio_upload(
|
|||
"has_transcription": bool # 原来是否有转录记录 (成功时)
|
||||
}
|
||||
"""
|
||||
print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, Type: {type(prompt_id)}")
|
||||
print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, model_code: {model_code}")
|
||||
|
||||
# 1. 权限和已有文件检查
|
||||
try:
|
||||
|
|
@ -136,18 +140,22 @@ def handle_audio_upload(
|
|||
|
||||
# 4. 启动转录任务
|
||||
try:
|
||||
transcription_task_id = transcription_service.start_transcription(meeting_id, file_path)
|
||||
transcription_task_id = transcription_service.start_transcription(
|
||||
meeting_id,
|
||||
file_path,
|
||||
business_task_id=transcription_task_id,
|
||||
)
|
||||
print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}")
|
||||
|
||||
# 5. 如果启用自动总结且提供了 background_tasks,添加监控任务
|
||||
if auto_summarize and transcription_task_id and background_tasks:
|
||||
background_tasks.add_task(
|
||||
async_meeting_service.monitor_and_auto_summarize,
|
||||
# 5. 如果启用自动总结,则提交后台监控任务
|
||||
if auto_summarize and transcription_task_id:
|
||||
async_meeting_service.enqueue_transcription_monitor(
|
||||
meeting_id,
|
||||
transcription_task_id,
|
||||
prompt_id # 传递 prompt_id 给自动总结监控任务
|
||||
prompt_id,
|
||||
model_code
|
||||
)
|
||||
print(f"[audio_service] Auto-summarize enabled, monitor task added for meeting {meeting_id}, prompt_id: {prompt_id}")
|
||||
print(f"[audio_service] Auto-summarize enabled, monitor task scheduled for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to start transcription: {e}")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
音频上传后台处理服务
|
||||
|
||||
将上传后的重操作放到后台线程执行,避免请求长时间阻塞:
|
||||
1. 音频预处理
|
||||
2. 更新音频文件记录
|
||||
3. 启动转录
|
||||
4. 启动自动总结监控
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import BACKGROUND_TASK_CONFIG, BASE_DIR
|
||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||
from app.services.audio_preprocess_service import audio_preprocess_service
|
||||
from app.services.audio_service import handle_audio_upload
|
||||
from app.services.background_task_runner import KeyedBackgroundTaskRunner
|
||||
|
||||
|
||||
upload_task_runner = KeyedBackgroundTaskRunner(
|
||||
max_workers=max(1, int(BACKGROUND_TASK_CONFIG.get("upload_workers", 2))),
|
||||
thread_name_prefix="imeeting-audio-upload",
|
||||
)
|
||||
|
||||
|
||||
class AudioUploadTaskService:
|
||||
def __init__(self):
|
||||
self.transcription_service = AsyncTranscriptionService()
|
||||
|
||||
def enqueue_upload_processing(
|
||||
self,
|
||||
*,
|
||||
meeting_id: int,
|
||||
original_file_path: str,
|
||||
current_user: dict,
|
||||
auto_summarize: bool,
|
||||
prompt_id: Optional[int] = None,
|
||||
model_code: Optional[str] = None,
|
||||
) -> str:
|
||||
task_id = self.transcription_service.create_local_processing_task(
|
||||
meeting_id=meeting_id,
|
||||
status="processing",
|
||||
progress=5,
|
||||
)
|
||||
upload_task_runner.submit(
|
||||
f"audio-upload:{task_id}",
|
||||
self._process_uploaded_audio,
|
||||
task_id,
|
||||
meeting_id,
|
||||
original_file_path,
|
||||
current_user,
|
||||
auto_summarize,
|
||||
prompt_id,
|
||||
model_code,
|
||||
)
|
||||
return task_id
|
||||
|
||||
def _process_uploaded_audio(
|
||||
self,
|
||||
task_id: str,
|
||||
meeting_id: int,
|
||||
original_file_path: str,
|
||||
current_user: dict,
|
||||
auto_summarize: bool,
|
||||
prompt_id: Optional[int],
|
||||
model_code: Optional[str],
|
||||
) -> None:
|
||||
source_absolute_path = BASE_DIR / original_file_path.lstrip("/")
|
||||
processed_absolute_path: Optional[Path] = None
|
||||
handoff_to_audio_service = False
|
||||
|
||||
try:
|
||||
self.transcription_service.update_local_processing_task(task_id, "processing", 15, None)
|
||||
|
||||
preprocess_result = audio_preprocess_service.preprocess(source_absolute_path)
|
||||
processed_absolute_path = preprocess_result.file_path
|
||||
audio_duration = preprocess_result.metadata.duration_seconds
|
||||
file_path = "/" + str(processed_absolute_path.relative_to(BASE_DIR))
|
||||
|
||||
print(
|
||||
f"[AudioUploadTaskService] 音频预处理完成: source={source_absolute_path.name}, "
|
||||
f"target={processed_absolute_path.name}, duration={audio_duration}s, "
|
||||
f"applied={preprocess_result.applied}"
|
||||
)
|
||||
|
||||
self.transcription_service.update_local_processing_task(
|
||||
task_id,
|
||||
"processing",
|
||||
self.transcription_service.PREPROCESS_COMPLETED_PROGRESS,
|
||||
None,
|
||||
)
|
||||
|
||||
handoff_to_audio_service = True
|
||||
result = handle_audio_upload(
|
||||
file_path=file_path,
|
||||
file_name=preprocess_result.file_name,
|
||||
file_size=preprocess_result.file_size,
|
||||
meeting_id=meeting_id,
|
||||
current_user=current_user,
|
||||
auto_summarize=auto_summarize,
|
||||
background_tasks=None,
|
||||
prompt_id=prompt_id,
|
||||
model_code=model_code,
|
||||
duration=audio_duration,
|
||||
transcription_task_id=task_id,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
raise RuntimeError(self._extract_response_message(result["response"]))
|
||||
|
||||
if preprocess_result.applied and processed_absolute_path != source_absolute_path and source_absolute_path.exists():
|
||||
try:
|
||||
os.remove(source_absolute_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as exc:
|
||||
error_message = str(exc)
|
||||
print(f"[AudioUploadTaskService] 音频后台处理失败, task_id={task_id}, meeting_id={meeting_id}: {error_message}")
|
||||
self.transcription_service.update_local_processing_task(task_id, "failed", 0, error_message)
|
||||
|
||||
if handoff_to_audio_service:
|
||||
return
|
||||
|
||||
cleanup_targets = []
|
||||
if processed_absolute_path:
|
||||
cleanup_targets.append(processed_absolute_path)
|
||||
if source_absolute_path.exists():
|
||||
cleanup_targets.append(source_absolute_path)
|
||||
|
||||
deduped_targets: list[Path] = []
|
||||
for target in cleanup_targets:
|
||||
if target not in deduped_targets:
|
||||
deduped_targets.append(target)
|
||||
|
||||
for target in deduped_targets:
|
||||
if target.exists():
|
||||
try:
|
||||
os.remove(target)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_message(response) -> str:
|
||||
body = getattr(response, "body", None)
|
||||
if not body:
|
||||
return "音频处理失败"
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
return payload.get("message") or "音频处理失败"
|
||||
except Exception:
|
||||
return "音频处理失败"
|
||||
|
||||
|
||||
audio_upload_task_service = AudioUploadTaskService()
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
from concurrent.futures import ThreadPoolExecutor
|
||||
from itertools import count
|
||||
from threading import Lock
|
||||
from typing import Any, Callable, Dict
|
||||
import traceback
|
||||
|
||||
|
||||
class KeyedBackgroundTaskRunner:
|
||||
"""按 key 去重的后台任务执行器,避免同类长任务重复堆积。"""
|
||||
|
||||
def __init__(self, max_workers: int, thread_name_prefix: str):
|
||||
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix=thread_name_prefix)
|
||||
self._lock = Lock()
|
||||
self._seq = count(1)
|
||||
self._tasks: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def submit(self, key: str, func: Callable[..., Any], *args: Any, **kwargs: Any) -> bool:
|
||||
with self._lock:
|
||||
current = self._tasks.get(key)
|
||||
if current and not current["future"].done():
|
||||
return False
|
||||
|
||||
token = next(self._seq)
|
||||
future = self._executor.submit(self._run_task, key, token, func, *args, **kwargs)
|
||||
self._tasks[key] = {"token": token, "future": future}
|
||||
return True
|
||||
|
||||
def _run_task(self, key: str, token: int, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except Exception:
|
||||
print(f"[BackgroundTaskRunner] Task failed, key={key}")
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
with self._lock:
|
||||
current = self._tasks.get(key)
|
||||
if current and current["token"] == token:
|
||||
self._tasks.pop(key, None)
|
||||
|
|
@ -3,21 +3,27 @@ import redis
|
|||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.config import REDIS_CONFIG
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
import os
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-super-secret-key-change-in-production')
|
||||
JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7天
|
||||
|
||||
class JWTService:
|
||||
def __init__(self):
|
||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
@staticmethod
|
||||
def _get_access_token_expire_minutes() -> int:
|
||||
expire_days = SystemConfigService.get_token_expire_days()
|
||||
return max(1, expire_days) * 24 * 60
|
||||
|
||||
def create_access_token(self, data: Dict[str, Any]) -> str:
|
||||
"""创建JWT访问令牌"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire_minutes = self._get_access_token_expire_minutes()
|
||||
expire = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||
|
|
@ -27,7 +33,7 @@ class JWTService:
|
|||
if user_id:
|
||||
self.redis_client.setex(
|
||||
f"token:{user_id}:{encoded_jwt}",
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Redis需要秒
|
||||
expire_minutes * 60, # Redis需要秒
|
||||
"active"
|
||||
)
|
||||
|
||||
|
|
@ -99,4 +105,4 @@ class JWTService:
|
|||
return self.create_access_token(new_data)
|
||||
|
||||
# 全局实例
|
||||
jwt_service = JWTService()
|
||||
jwt_service = JWTService()
|
||||
|
|
|
|||
|
|
@ -1,32 +1,312 @@
|
|||
import json
|
||||
import dashscope
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Dict, List, Generator, Any
|
||||
import app.core.config as config_module
|
||||
import os
|
||||
from typing import Optional, Dict, Generator, Any, List
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.database import get_db_connection
|
||||
from app.services.system_config_service import SystemConfigService
|
||||
|
||||
|
||||
class LLMServiceError(Exception):
|
||||
"""LLM 调用失败时抛出的结构化异常。"""
|
||||
|
||||
def __init__(self, message: str, *, status_code: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""LLM服务 - 专注于大模型API调用和提示词管理"""
|
||||
|
||||
def __init__(self):
|
||||
# 设置dashscope API key
|
||||
dashscope.api_key = config_module.QWEN_API_KEY
|
||||
@staticmethod
|
||||
def _use_system_proxy() -> bool:
|
||||
return os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@staticmethod
|
||||
def _create_httpx_client() -> httpx.Client:
|
||||
return httpx.Client(
|
||||
trust_env=LLMService._use_system_proxy()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_int(value: Any, default: int, minimum: Optional[int] = None) -> int:
|
||||
try:
|
||||
normalized = int(value)
|
||||
except (TypeError, ValueError):
|
||||
normalized = default
|
||||
if minimum is not None:
|
||||
normalized = max(minimum, normalized)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _coerce_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _build_timeout(timeout_seconds: int) -> httpx.Timeout:
|
||||
normalized_timeout = max(1, int(timeout_seconds))
|
||||
connect_timeout = min(10.0, float(normalized_timeout))
|
||||
return httpx.Timeout(
|
||||
connect=connect_timeout,
|
||||
read=float(normalized_timeout),
|
||||
write=float(normalized_timeout),
|
||||
pool=connect_timeout,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_api_key(api_key: Optional[Any]) -> Optional[str]:
|
||||
if api_key is None:
|
||||
return None
|
||||
normalized = str(api_key).strip()
|
||||
return normalized or None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model_code(model_code: Optional[Any]) -> Optional[str]:
|
||||
if model_code is None:
|
||||
return None
|
||||
normalized = str(model_code).strip()
|
||||
return normalized or None
|
||||
|
||||
@classmethod
|
||||
def build_call_params_from_config(cls, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
config = config or {}
|
||||
endpoint_url = str(config.get("endpoint_url") or SystemConfigService.get_llm_endpoint_url() or "").strip()
|
||||
api_key = cls._normalize_api_key(config.get("api_key"))
|
||||
if api_key is None:
|
||||
api_key = cls._normalize_api_key(SystemConfigService.get_llm_api_key())
|
||||
|
||||
default_model = SystemConfigService.get_llm_model_name()
|
||||
default_timeout = SystemConfigService.get_llm_timeout()
|
||||
default_temperature = SystemConfigService.get_llm_temperature()
|
||||
default_top_p = SystemConfigService.get_llm_top_p()
|
||||
default_max_tokens = SystemConfigService.get_llm_max_tokens()
|
||||
default_system_prompt = SystemConfigService.get_llm_system_prompt(None)
|
||||
|
||||
return {
|
||||
"endpoint_url": endpoint_url,
|
||||
"api_key": api_key,
|
||||
"model": str(
|
||||
config.get("llm_model_name")
|
||||
or config.get("model")
|
||||
or config.get("model_name")
|
||||
or default_model
|
||||
).strip(),
|
||||
"timeout": cls._coerce_int(
|
||||
config.get("llm_timeout")
|
||||
or config.get("timeout")
|
||||
or config.get("time_out")
|
||||
or default_timeout,
|
||||
default_timeout,
|
||||
minimum=1,
|
||||
),
|
||||
"temperature": cls._coerce_float(
|
||||
config.get("llm_temperature") if config.get("llm_temperature") is not None else config.get("temperature"),
|
||||
default_temperature,
|
||||
),
|
||||
"top_p": cls._coerce_float(
|
||||
config.get("llm_top_p") if config.get("llm_top_p") is not None else config.get("top_p"),
|
||||
default_top_p,
|
||||
),
|
||||
"max_tokens": cls._coerce_int(
|
||||
config.get("llm_max_tokens") or config.get("max_tokens") or default_max_tokens,
|
||||
default_max_tokens,
|
||||
minimum=1,
|
||||
),
|
||||
"system_prompt": config.get("llm_system_prompt") or config.get("system_prompt") or default_system_prompt,
|
||||
}
|
||||
|
||||
def _get_llm_call_params(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取 dashscope.Generation.call() 所需的参数字典
|
||||
获取 OpenAI 兼容接口调用参数
|
||||
|
||||
Returns:
|
||||
Dict: 包含 model、timeout、temperature、top_p 的参数字典
|
||||
Dict: 包含 endpoint_url、api_key、model、timeout、temperature、top_p、max_tokens 的参数字典
|
||||
"""
|
||||
return {
|
||||
'model': SystemConfigService.get_llm_model_name(),
|
||||
'timeout': SystemConfigService.get_llm_timeout(),
|
||||
'temperature': SystemConfigService.get_llm_temperature(),
|
||||
'top_p': SystemConfigService.get_llm_top_p(),
|
||||
return self.build_call_params_from_config()
|
||||
|
||||
@staticmethod
|
||||
def _build_chat_url(endpoint_url: str) -> str:
|
||||
base_url = (endpoint_url or "").rstrip("/")
|
||||
if base_url.endswith("/chat/completions"):
|
||||
return base_url
|
||||
return f"{base_url}/chat/completions"
|
||||
|
||||
@staticmethod
|
||||
def _build_headers(api_key: Optional[str]) -> Dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
def _normalize_messages(
|
||||
self,
|
||||
prompt: Optional[str] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> List[Dict[str, str]]:
|
||||
normalized_messages: List[Dict[str, str]] = []
|
||||
|
||||
if messages is not None:
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
role = str(message.get("role") or "").strip()
|
||||
if not role:
|
||||
continue
|
||||
content = self._normalize_content(message.get("content"))
|
||||
if not content:
|
||||
continue
|
||||
normalized_messages.append({"role": role, "content": content})
|
||||
return normalized_messages
|
||||
|
||||
if prompt is not None:
|
||||
prompt_content = self._normalize_content(prompt)
|
||||
if prompt_content:
|
||||
normalized_messages.append({"role": "user", "content": prompt_content})
|
||||
|
||||
return normalized_messages
|
||||
|
||||
@staticmethod
|
||||
def _merge_system_messages(
|
||||
messages: List[Dict[str, str]],
|
||||
base_system_prompt: Optional[str],
|
||||
) -> List[Dict[str, str]]:
|
||||
merged_messages: List[Dict[str, str]] = []
|
||||
merged_system_parts: List[str] = []
|
||||
|
||||
if isinstance(base_system_prompt, str) and base_system_prompt.strip():
|
||||
merged_system_parts.append(base_system_prompt.strip())
|
||||
|
||||
index = 0
|
||||
while index < len(messages) and messages[index].get("role") == "system":
|
||||
content = str(messages[index].get("content") or "").strip()
|
||||
if content:
|
||||
merged_system_parts.append(content)
|
||||
index += 1
|
||||
|
||||
if merged_system_parts:
|
||||
merged_messages.append({"role": "system", "content": "\n\n".join(merged_system_parts)})
|
||||
|
||||
merged_messages.extend(messages[index:])
|
||||
return merged_messages
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
prompt: Optional[str] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
stream: bool = False,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
params = params or self._get_llm_call_params()
|
||||
normalized_messages = self._normalize_messages(prompt=prompt, messages=messages)
|
||||
normalized_messages = self._merge_system_messages(normalized_messages, params.get("system_prompt"))
|
||||
|
||||
if not normalized_messages:
|
||||
raise ValueError("缺少 prompt 或 messages")
|
||||
|
||||
payload = {
|
||||
"model": params["model"],
|
||||
"messages": normalized_messages,
|
||||
"temperature": params["temperature"],
|
||||
"top_p": params["top_p"],
|
||||
"max_tokens": params["max_tokens"],
|
||||
"stream": stream,
|
||||
}
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _normalize_content(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
texts = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
texts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if text:
|
||||
texts.append(text)
|
||||
return "".join(texts)
|
||||
return ""
|
||||
|
||||
def _extract_response_text(self, data: Dict[str, Any]) -> str:
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
|
||||
first_choice = choices[0] or {}
|
||||
message = first_choice.get("message") or {}
|
||||
content = message.get("content")
|
||||
if content:
|
||||
return self._normalize_content(content)
|
||||
|
||||
delta = first_choice.get("delta") or {}
|
||||
delta_content = delta.get("content")
|
||||
if delta_content:
|
||||
return self._normalize_content(delta_content)
|
||||
|
||||
return ""
|
||||
|
||||
def _validate_call_params(self, params: Dict[str, Any]) -> Optional[str]:
|
||||
if not params.get("endpoint_url"):
|
||||
return "缺少 endpoint_url"
|
||||
if not params.get("model"):
|
||||
return "缺少 model"
|
||||
if not params.get("api_key"):
|
||||
return "缺少API Key"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_error_message_from_response(response: httpx.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
parts = [
|
||||
str(error.get("message") or "").strip(),
|
||||
str(error.get("type") or "").strip(),
|
||||
str(error.get("code") or "").strip(),
|
||||
]
|
||||
message = " / ".join(part for part in parts if part)
|
||||
if message:
|
||||
return message
|
||||
if isinstance(error, str) and error.strip():
|
||||
return error.strip()
|
||||
message = payload.get("message")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
|
||||
text = (response.text or "").strip()
|
||||
return text[:500] if text else f"HTTP {response.status_code}"
|
||||
|
||||
def get_call_params_by_model_code(self, model_code: Optional[str] = None) -> Dict[str, Any]:
|
||||
normalized_model_code = self._normalize_model_code(model_code)
|
||||
if not normalized_model_code:
|
||||
return self._get_llm_call_params()
|
||||
|
||||
runtime_config = SystemConfigService.get_model_runtime_config(normalized_model_code)
|
||||
if not runtime_config:
|
||||
raise LLMServiceError(f"指定模型不可用: {normalized_model_code}")
|
||||
|
||||
return self.build_call_params_from_config(runtime_config)
|
||||
|
||||
def _resolve_call_params(
|
||||
self,
|
||||
model_code: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if config is not None:
|
||||
return self.build_call_params_from_config(config)
|
||||
return self.get_call_params_by_model_code(model_code)
|
||||
|
||||
def get_task_prompt(self, task_type: str, cursor=None, prompt_id: Optional[int] = None) -> str:
|
||||
"""
|
||||
|
|
@ -38,7 +318,7 @@ class LLMService:
|
|||
prompt_id: 可选的提示词ID,如果指定则使用该提示词,否则使用默认提示词
|
||||
|
||||
Returns:
|
||||
str: 提示词内容,如果未找到返回默认提示词
|
||||
str: 提示词内容
|
||||
"""
|
||||
# 如果指定了 prompt_id,直接获取该提示词
|
||||
if prompt_id:
|
||||
|
|
@ -74,63 +354,191 @@ class LLMService:
|
|||
if result:
|
||||
return result['content']
|
||||
|
||||
# 返回默认提示词
|
||||
return self._get_default_prompt(task_type)
|
||||
prompt_label = f"ID={prompt_id}" if prompt_id else f"task_type={task_type} 的默认模版"
|
||||
raise LLMServiceError(f"未找到可用提示词模版:{prompt_label}")
|
||||
|
||||
def _get_default_prompt(self, task_name: str) -> str:
|
||||
"""获取默认提示词"""
|
||||
system_prompt = config_module.LLM_CONFIG.get("system_prompt", "请根据提供的内容进行总结和分析。")
|
||||
default_prompts = {
|
||||
'MEETING_TASK': system_prompt,
|
||||
'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。",
|
||||
}
|
||||
return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。")
|
||||
|
||||
def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]:
|
||||
"""流式调用阿里Qwen大模型API"""
|
||||
def stream_llm_api(
|
||||
self,
|
||||
prompt: Optional[str] = None,
|
||||
model_code: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""流式调用 OpenAI 兼容大模型API。"""
|
||||
try:
|
||||
responses = dashscope.Generation.call(
|
||||
**self._get_llm_call_params(),
|
||||
prompt=prompt,
|
||||
stream=True,
|
||||
incremental_output=True
|
||||
)
|
||||
params = self._resolve_call_params(model_code=model_code, config=config)
|
||||
validation_error = self._validate_call_params(params)
|
||||
if validation_error:
|
||||
yield f"error: {validation_error}"
|
||||
return
|
||||
|
||||
for response in responses:
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
# 增量输出内容
|
||||
new_content = response.output.get('text', '')
|
||||
if new_content:
|
||||
yield new_content
|
||||
else:
|
||||
error_msg = f"Request failed with status code: {response.status_code}, Error: {response.message}"
|
||||
print(error_msg)
|
||||
yield f"error: {error_msg}"
|
||||
break
|
||||
timeout = self._build_timeout(params["timeout"])
|
||||
with self._create_httpx_client() as client:
|
||||
with client.stream(
|
||||
"POST",
|
||||
self._build_chat_url(params["endpoint_url"]),
|
||||
headers=self._build_headers(params["api_key"]),
|
||||
json=self._build_payload(prompt=prompt, messages=messages, stream=True, params=params),
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_line = line[5:].strip()
|
||||
if not data_line or data_line == "[DONE]":
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(data_line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
new_content = self._extract_response_text(data)
|
||||
if new_content:
|
||||
yield new_content
|
||||
except LLMServiceError as e:
|
||||
error_msg = e.message or str(e)
|
||||
print(f"流式调用大模型API错误: {error_msg}")
|
||||
yield f"error: {error_msg}"
|
||||
except httpx.HTTPStatusError as e:
|
||||
detail = self._extract_error_message_from_response(e.response)
|
||||
error_msg = f"流式调用大模型API错误: HTTP {e.response.status_code} - {detail}"
|
||||
print(error_msg)
|
||||
yield f"error: {error_msg}"
|
||||
except httpx.TimeoutException:
|
||||
error_msg = f"流式调用大模型API超时: timeout={params['timeout']}s"
|
||||
print(error_msg)
|
||||
yield f"error: {error_msg}"
|
||||
except httpx.RequestError as e:
|
||||
error_msg = f"流式调用大模型API网络错误: {e}"
|
||||
print(error_msg)
|
||||
yield f"error: {error_msg}"
|
||||
except Exception as e:
|
||||
error_msg = f"流式调用大模型API错误: {e}"
|
||||
print(error_msg)
|
||||
yield f"error: {error_msg}"
|
||||
|
||||
def _call_llm_api(self, prompt: str) -> Optional[str]:
|
||||
"""调用阿里Qwen大模型API(非流式)"""
|
||||
def call_llm_api(
|
||||
self,
|
||||
prompt: Optional[str] = None,
|
||||
model_code: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[str]:
|
||||
"""调用 OpenAI 兼容大模型API(非流式)。"""
|
||||
try:
|
||||
response = dashscope.Generation.call(
|
||||
**self._get_llm_call_params(),
|
||||
prompt=prompt
|
||||
return self.call_llm_api_or_raise(
|
||||
prompt=prompt,
|
||||
model_code=model_code,
|
||||
config=config,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
if response.status_code == HTTPStatus.OK:
|
||||
return response.output.get('text', '')
|
||||
else:
|
||||
print(f"API调用失败: {response.status_code}, {response.message}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
except LLMServiceError as e:
|
||||
print(f"调用大模型API错误: {e}")
|
||||
return None
|
||||
|
||||
def call_llm_api_or_raise(
|
||||
self,
|
||||
prompt: Optional[str] = None,
|
||||
model_code: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
"""调用 OpenAI 兼容大模型API(非流式),失败时抛出结构化异常。"""
|
||||
params = self._resolve_call_params(model_code=model_code, config=config)
|
||||
return self.call_llm_api_with_config_or_raise(params, prompt=prompt, messages=messages)
|
||||
|
||||
def call_llm_api_messages(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model_code: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
"""使用多消息结构调用 OpenAI 兼容大模型API(非流式)。"""
|
||||
return self.call_llm_api(prompt=None, model_code=model_code, config=config, messages=messages)
|
||||
|
||||
def call_llm_api_messages_or_raise(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
model_code: Optional[str] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""使用多消息结构调用 OpenAI 兼容大模型API(非流式),失败时抛出结构化异常。"""
|
||||
return self.call_llm_api_or_raise(prompt=None, model_code=model_code, config=config, messages=messages)
|
||||
|
||||
def call_llm_api_with_config(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
prompt: Optional[str] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Optional[str]:
|
||||
"""使用指定配置调用 OpenAI 兼容大模型API(非流式)"""
|
||||
try:
|
||||
return self.call_llm_api_with_config_or_raise(params, prompt=prompt, messages=messages)
|
||||
except LLMServiceError as e:
|
||||
print(f"调用大模型API错误: {e}")
|
||||
return None
|
||||
|
||||
def call_llm_api_with_config_or_raise(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
prompt: Optional[str] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
"""使用指定配置调用 OpenAI 兼容大模型API(非流式),失败时抛出结构化异常。"""
|
||||
validation_error = self._validate_call_params(params)
|
||||
if validation_error:
|
||||
raise LLMServiceError(validation_error)
|
||||
|
||||
timeout = self._build_timeout(params["timeout"])
|
||||
try:
|
||||
with self._create_httpx_client() as client:
|
||||
response = client.post(
|
||||
self._build_chat_url(params["endpoint_url"]),
|
||||
headers=self._build_headers(params["api_key"]),
|
||||
json=self._build_payload(prompt=prompt, messages=messages, params=params),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = self._extract_response_text(response.json())
|
||||
if content:
|
||||
return content
|
||||
raise LLMServiceError("API调用失败: 返回内容为空")
|
||||
except httpx.HTTPStatusError as e:
|
||||
detail = self._extract_error_message_from_response(e.response)
|
||||
raise LLMServiceError(
|
||||
f"HTTP {e.response.status_code} - {detail}",
|
||||
status_code=e.response.status_code,
|
||||
) from e
|
||||
except httpx.TimeoutException:
|
||||
raise LLMServiceError(f"调用超时: timeout={params['timeout']}s")
|
||||
except httpx.RequestError as e:
|
||||
raise LLMServiceError(f"网络错误: {e}") from e
|
||||
except Exception as e:
|
||||
raise LLMServiceError(str(e)) from e
|
||||
|
||||
def test_model(self, config: Dict[str, Any], prompt: Optional[str] = None) -> Dict[str, Any]:
|
||||
params = self.build_call_params_from_config(config)
|
||||
test_prompt = prompt or "请用一句中文回复:LLM测试成功。"
|
||||
content = self.call_llm_api_with_config_or_raise(params, test_prompt)
|
||||
if not content:
|
||||
raise Exception("模型无有效返回内容")
|
||||
|
||||
return {
|
||||
"model": params["model"],
|
||||
"endpoint_url": params["endpoint_url"],
|
||||
"response_preview": content[:500],
|
||||
"used_params": {
|
||||
"timeout": params["timeout"],
|
||||
"temperature": params["temperature"],
|
||||
"top_p": params["top_p"],
|
||||
"max_tokens": params["max_tokens"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,18 +1,32 @@
|
|||
import json
|
||||
import time
|
||||
from threading import RLock
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
|
||||
class SystemConfigService:
|
||||
"""系统配置服务 - 从 dict_data 表中读取和保存 system_config 类型的配置"""
|
||||
"""系统配置服务。"""
|
||||
|
||||
DICT_TYPE = 'system_config'
|
||||
PUBLIC_CATEGORY = 'public'
|
||||
DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
CACHE_TTL_SECONDS = 60
|
||||
|
||||
# 配置键常量
|
||||
ASR_VOCABULARY_ID = 'asr_vocabulary_id'
|
||||
TIMELINE_PAGESIZE = 'timeline_pagesize'
|
||||
PAGE_SIZE = 'page_size'
|
||||
DEFAULT_RESET_PASSWORD = 'default_reset_password'
|
||||
MAX_AUDIO_SIZE = 'max_audio_size'
|
||||
MAX_IMAGE_SIZE = 'max_image_size'
|
||||
TOKEN_EXPIRE_DAYS = 'token_expire_days'
|
||||
|
||||
# 品牌配置
|
||||
APP_NAME = 'app_name'
|
||||
DEPRECATED_BRANDING_PARAMETER_KEYS = frozenset({
|
||||
'console_subtitle',
|
||||
'preview_title',
|
||||
'login_welcome',
|
||||
'footer_text',
|
||||
})
|
||||
|
||||
# 声纹配置
|
||||
VOICEPRINT_TEMPLATE_TEXT = 'voiceprint_template_text'
|
||||
|
|
@ -26,6 +40,273 @@ class SystemConfigService:
|
|||
LLM_TIMEOUT = 'llm_timeout'
|
||||
LLM_TEMPERATURE = 'llm_temperature'
|
||||
LLM_TOP_P = 'llm_top_p'
|
||||
_cache_lock = RLock()
|
||||
_config_cache: Dict[str, tuple[float, Any]] = {}
|
||||
_category_cache: Dict[str, tuple[float, Dict[str, Any]]] = {}
|
||||
_all_configs_cache: tuple[float, Dict[str, Any]] | None = None
|
||||
BUILTIN_PARAMETERS = [
|
||||
{
|
||||
"param_key": TOKEN_EXPIRE_DAYS,
|
||||
"param_name": "Token过期时间",
|
||||
"param_value": "7",
|
||||
"value_type": "number",
|
||||
"category": "system",
|
||||
"description": "控制登录 token 的过期时间,单位:天。",
|
||||
"is_active": 1,
|
||||
},
|
||||
{
|
||||
"param_key": DEFAULT_RESET_PASSWORD,
|
||||
"param_name": "默认重置密码",
|
||||
"param_value": "123456",
|
||||
"value_type": "string",
|
||||
"category": "system",
|
||||
"description": "管理员重置用户密码时使用的默认密码。",
|
||||
"is_active": 1,
|
||||
},
|
||||
{
|
||||
"param_key": PAGE_SIZE,
|
||||
"param_name": "系统分页大小",
|
||||
"param_value": "10",
|
||||
"value_type": "number",
|
||||
"category": PUBLIC_CATEGORY,
|
||||
"description": "系统通用分页数量。",
|
||||
"is_active": 1,
|
||||
},
|
||||
{
|
||||
"param_key": MAX_AUDIO_SIZE,
|
||||
"param_name": "音频上传大小限制",
|
||||
"param_value": "100",
|
||||
"value_type": "number",
|
||||
"category": PUBLIC_CATEGORY,
|
||||
"description": "音频上传大小限制,单位:MB。",
|
||||
"is_active": 1,
|
||||
},
|
||||
{
|
||||
"param_key": MAX_IMAGE_SIZE,
|
||||
"param_name": "图片上传大小限制",
|
||||
"param_value": "10",
|
||||
"value_type": "number",
|
||||
"category": PUBLIC_CATEGORY,
|
||||
"description": "图片上传大小限制,单位:MB。",
|
||||
"is_active": 1,
|
||||
},
|
||||
{
|
||||
"param_key": APP_NAME,
|
||||
"param_name": "系统名称",
|
||||
"param_value": "iMeeting",
|
||||
"value_type": "string",
|
||||
"category": PUBLIC_CATEGORY,
|
||||
"description": "前端应用标题。",
|
||||
"is_active": 1,
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _is_cache_valid(cls, cached_at: float) -> bool:
|
||||
return (time.time() - cached_at) < cls.CACHE_TTL_SECONDS
|
||||
|
||||
@classmethod
|
||||
def _get_cached_config(cls, cache_key: str) -> Any:
|
||||
with cls._cache_lock:
|
||||
cached = cls._config_cache.get(cache_key)
|
||||
if not cached:
|
||||
return None
|
||||
cached_at, value = cached
|
||||
if not cls._is_cache_valid(cached_at):
|
||||
cls._config_cache.pop(cache_key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _set_cached_config(cls, cache_key: str, value: Any) -> None:
|
||||
with cls._cache_lock:
|
||||
cls._config_cache[cache_key] = (time.time(), value)
|
||||
|
||||
@classmethod
|
||||
def invalidate_cache(cls) -> None:
|
||||
with cls._cache_lock:
|
||||
cls._config_cache.clear()
|
||||
cls._category_cache.clear()
|
||||
cls._all_configs_cache = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(value: Any) -> Dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_string_list(value: Any) -> Optional[list[str]]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
items = [str(item).strip() for item in value if str(item).strip()]
|
||||
return items or None
|
||||
if isinstance(value, str):
|
||||
items = [item.strip() for item in value.split(",") if item.strip()]
|
||||
return items or None
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _build_audio_runtime_config(cls, audio_row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cfg: Dict[str, Any] = {}
|
||||
if not audio_row:
|
||||
return cfg
|
||||
|
||||
extra_config = cls._parse_json_object(audio_row.get("extra_config"))
|
||||
|
||||
if audio_row.get("endpoint_url"):
|
||||
cfg["endpoint_url"] = audio_row["endpoint_url"]
|
||||
if audio_row.get("api_key"):
|
||||
cfg["api_key"] = audio_row["api_key"]
|
||||
if audio_row.get("provider"):
|
||||
cfg["provider"] = audio_row["provider"]
|
||||
if audio_row.get("model_code"):
|
||||
cfg["model_code"] = audio_row["model_code"]
|
||||
if audio_row.get("audio_scene"):
|
||||
cfg["audio_scene"] = audio_row["audio_scene"]
|
||||
if audio_row.get("hot_word_group_id") is not None:
|
||||
cfg["hot_word_group_id"] = audio_row["hot_word_group_id"]
|
||||
if audio_row.get("request_timeout_seconds") is not None:
|
||||
cfg["request_timeout_seconds"] = int(audio_row["request_timeout_seconds"])
|
||||
|
||||
language_hints = cls._normalize_string_list(extra_config.get("language_hints"))
|
||||
if language_hints is not None:
|
||||
extra_config["language_hints"] = language_hints
|
||||
|
||||
cfg.update(extra_config)
|
||||
return cfg
|
||||
|
||||
@classmethod
|
||||
def get_active_audio_model_config(cls, scene: str = "asr") -> Dict[str, Any]:
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key,
|
||||
request_timeout_seconds, hot_word_group_id, extra_config
|
||||
FROM audio_model_config
|
||||
WHERE audio_scene = %s AND is_active = 1
|
||||
ORDER BY is_default DESC, updated_at DESC, config_id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(scene,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
cursor.close()
|
||||
return cls._build_audio_runtime_config(row) if row else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def _get_parameter_value(cls, param_key: str):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT param_value
|
||||
FROM sys_system_parameters
|
||||
WHERE param_key = %s AND is_active = 1
|
||||
LIMIT 1
|
||||
""",
|
||||
(param_key,),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
return result["param_value"] if result else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _get_model_config_json(cls, model_code: str):
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
# 1) llm 专表
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout,
|
||||
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
|
||||
FROM llm_model_config
|
||||
WHERE model_code = %s AND is_active = 1
|
||||
ORDER BY is_default DESC, config_id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(model_code,),
|
||||
)
|
||||
llm_row = cursor.fetchone()
|
||||
if not llm_row and model_code == "llm_model":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout,
|
||||
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
|
||||
FROM llm_model_config
|
||||
WHERE is_active = 1
|
||||
ORDER BY is_default DESC, updated_at DESC, config_id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
llm_row = cursor.fetchone()
|
||||
if llm_row:
|
||||
cursor.close()
|
||||
cfg = {}
|
||||
if llm_row.get("endpoint_url"):
|
||||
cfg["endpoint_url"] = llm_row["endpoint_url"]
|
||||
if llm_row.get("api_key"):
|
||||
cfg["api_key"] = llm_row["api_key"]
|
||||
if llm_row.get("llm_model_name") is not None:
|
||||
cfg["model_name"] = llm_row["llm_model_name"]
|
||||
if llm_row.get("llm_timeout") is not None:
|
||||
cfg["time_out"] = llm_row["llm_timeout"]
|
||||
if llm_row.get("llm_temperature") is not None:
|
||||
cfg["temperature"] = float(llm_row["llm_temperature"])
|
||||
if llm_row.get("llm_top_p") is not None:
|
||||
cfg["top_p"] = float(llm_row["llm_top_p"])
|
||||
if llm_row.get("llm_max_tokens") is not None:
|
||||
cfg["max_tokens"] = llm_row["llm_max_tokens"]
|
||||
if llm_row.get("llm_system_prompt") is not None:
|
||||
cfg["system_prompt"] = llm_row["llm_system_prompt"]
|
||||
return cfg
|
||||
|
||||
# 2) audio 专表
|
||||
if model_code in ("audio_model", "voiceprint_model"):
|
||||
target_scene = "voiceprint" if model_code == "voiceprint_model" else "asr"
|
||||
cursor.close()
|
||||
audio_cfg = cls.get_active_audio_model_config(target_scene)
|
||||
return audio_cfg or None
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key,
|
||||
request_timeout_seconds, hot_word_group_id, extra_config
|
||||
FROM audio_model_config
|
||||
WHERE model_code = %s AND is_active = 1
|
||||
ORDER BY is_default DESC, config_id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(model_code,),
|
||||
)
|
||||
audio_row = cursor.fetchone()
|
||||
cursor.close()
|
||||
if audio_row:
|
||||
return cls._build_audio_runtime_config(audio_row)
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
|
||||
|
|
@ -39,31 +320,18 @@ class SystemConfigService:
|
|||
Returns:
|
||||
配置项的值
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT extension_attr
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND dict_code = %s AND status = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (cls.DICT_TYPE, dict_code))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
cached_value = cls._get_cached_config(dict_code)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
if result and result['extension_attr']:
|
||||
try:
|
||||
ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr']
|
||||
return ext_attr.get('value', default_value)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
# 1) 新参数表
|
||||
value = cls._get_parameter_value(dict_code)
|
||||
if value is not None:
|
||||
cls._set_cached_config(dict_code, value)
|
||||
return value
|
||||
|
||||
return default_value
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting config {dict_code}: {e}")
|
||||
return default_value
|
||||
cls._set_cached_config(dict_code, default_value)
|
||||
return default_value
|
||||
|
||||
@classmethod
|
||||
def get_config_attribute(cls, dict_code: str, attr_name: str, default_value: Any = None) -> Any:
|
||||
|
|
@ -80,31 +348,17 @@ class SystemConfigService:
|
|||
Returns:
|
||||
属性值
|
||||
"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT extension_attr
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND dict_code = %s AND status = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
cursor.execute(query, (cls.DICT_TYPE, dict_code))
|
||||
result = cursor.fetchone()
|
||||
cursor.close()
|
||||
# 1) 新模型配置表
|
||||
model_json = cls._get_model_config_json(dict_code)
|
||||
if model_json is not None:
|
||||
return model_json.get(attr_name, default_value)
|
||||
|
||||
if result and result['extension_attr']:
|
||||
try:
|
||||
ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr']
|
||||
return ext_attr.get(attr_name, default_value)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
return default_value
|
||||
|
||||
return default_value
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting config attribute {dict_code}.{attr_name}: {e}")
|
||||
return default_value
|
||||
@classmethod
|
||||
def get_model_runtime_config(cls, model_code: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取模型运行时配置,优先从新模型配置表读取。"""
|
||||
return cls._get_model_config_json(model_code)
|
||||
|
||||
@classmethod
|
||||
def set_config(cls, dict_code: str, value: Any, label_cn: str = None) -> bool:
|
||||
|
|
@ -119,47 +373,70 @@ class SystemConfigService:
|
|||
Returns:
|
||||
是否设置成功
|
||||
"""
|
||||
# 1) 优先写入新参数表
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# 检查配置是否存在
|
||||
cursor.execute(
|
||||
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
|
||||
(cls.DICT_TYPE, dict_code)
|
||||
"""
|
||||
INSERT INTO sys_system_parameters
|
||||
(param_key, param_name, param_value, value_type, category, description, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
param_name = VALUES(param_name),
|
||||
param_value = VALUES(param_value),
|
||||
value_type = VALUES(value_type),
|
||||
category = VALUES(category),
|
||||
description = VALUES(description),
|
||||
is_active = 1
|
||||
""",
|
||||
(
|
||||
dict_code,
|
||||
label_cn or dict_code,
|
||||
str(value) if value is not None else "",
|
||||
"string",
|
||||
"system",
|
||||
"Migrated from legacy system_config",
|
||||
),
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
|
||||
extension_attr = json.dumps({"value": value}, ensure_ascii=False)
|
||||
|
||||
if existing:
|
||||
# 更新现有配置
|
||||
update_query = """
|
||||
UPDATE dict_data
|
||||
SET extension_attr = %s, update_time = NOW()
|
||||
WHERE dict_type = %s AND dict_code = %s
|
||||
"""
|
||||
cursor.execute(update_query, (extension_attr, cls.DICT_TYPE, dict_code))
|
||||
else:
|
||||
# 插入新配置
|
||||
if not label_cn:
|
||||
label_cn = dict_code
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO dict_data (
|
||||
dict_type, dict_code, parent_code, label_cn,
|
||||
extension_attr, status, sort_order
|
||||
) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0)
|
||||
"""
|
||||
cursor.execute(insert_query, (cls.DICT_TYPE, dict_code, label_cn, extension_attr))
|
||||
|
||||
if dict_code == cls.ASR_VOCABULARY_ID:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO audio_model_config
|
||||
(model_code, model_name, audio_scene, provider, request_timeout_seconds, extra_config, description, is_active, is_default)
|
||||
VALUES (
|
||||
'audio_model',
|
||||
'音频识别模型',
|
||||
'asr',
|
||||
'dashscope',
|
||||
300,
|
||||
JSON_OBJECT(
|
||||
'model', 'paraformer-v2',
|
||||
'vocabulary_id', %s,
|
||||
'speaker_count', 10,
|
||||
'language_hints', JSON_ARRAY('zh', 'en'),
|
||||
'disfluency_removal_enabled', TRUE,
|
||||
'diarization_enabled', TRUE
|
||||
),
|
||||
'语音识别模型配置',
|
||||
1,
|
||||
1
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
extra_config = JSON_SET(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id', %s),
|
||||
is_active = 1
|
||||
""",
|
||||
(str(value), str(value)),
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
cls.invalidate_cache()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error setting config {dict_code}: {e}")
|
||||
return False
|
||||
print(f"Error setting config in sys_system_parameters {dict_code}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_all_configs(cls) -> Dict[str, Any]:
|
||||
|
|
@ -169,34 +446,64 @@ class SystemConfigService:
|
|||
Returns:
|
||||
配置字典 {dict_code: value}
|
||||
"""
|
||||
with cls._cache_lock:
|
||||
if cls._all_configs_cache and cls._is_cache_valid(cls._all_configs_cache[0]):
|
||||
return dict(cls._all_configs_cache[1])
|
||||
if cls._all_configs_cache and not cls._is_cache_valid(cls._all_configs_cache[0]):
|
||||
cls._all_configs_cache = None
|
||||
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
query = """
|
||||
SELECT dict_code, label_cn, extension_attr
|
||||
FROM dict_data
|
||||
WHERE dict_type = %s AND status = 1
|
||||
ORDER BY sort_order
|
||||
"""
|
||||
cursor.execute(query, (cls.DICT_TYPE,))
|
||||
results = cursor.fetchall()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT param_key, param_value
|
||||
FROM sys_system_parameters
|
||||
WHERE is_active = 1
|
||||
ORDER BY category, param_key
|
||||
"""
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
configs = {}
|
||||
for row in results:
|
||||
if row['extension_attr']:
|
||||
try:
|
||||
ext_attr = json.loads(row['extension_attr']) if isinstance(row['extension_attr'], str) else row['extension_attr']
|
||||
configs[row['dict_code']] = ext_attr.get('value')
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
configs[row['dict_code']] = None
|
||||
else:
|
||||
configs[row['dict_code']] = None
|
||||
|
||||
return configs
|
||||
|
||||
if rows:
|
||||
configs = {row["param_key"]: row["param_value"] for row in rows}
|
||||
with cls._cache_lock:
|
||||
cls._all_configs_cache = (time.time(), configs)
|
||||
return dict(configs)
|
||||
except Exception as e:
|
||||
print(f"Error getting all configs: {e}")
|
||||
print(f"Error getting all configs from sys_system_parameters: {e}")
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_configs_by_category(cls, category: str) -> Dict[str, Any]:
|
||||
"""按分类获取启用中的参数配置。"""
|
||||
with cls._cache_lock:
|
||||
cached = cls._category_cache.get(category)
|
||||
if cached and cls._is_cache_valid(cached[0]):
|
||||
return dict(cached[1])
|
||||
if cached and not cls._is_cache_valid(cached[0]):
|
||||
cls._category_cache.pop(category, None)
|
||||
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT param_key, param_value
|
||||
FROM sys_system_parameters
|
||||
WHERE is_active = 1 AND category = %s
|
||||
ORDER BY param_key
|
||||
""",
|
||||
(category,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
configs = {row["param_key"]: row["param_value"] for row in rows} if rows else {}
|
||||
with cls._cache_lock:
|
||||
cls._category_cache[category] = (time.time(), configs)
|
||||
return dict(configs)
|
||||
except Exception as e:
|
||||
print(f"Error getting configs by category {category}: {e}")
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -216,22 +523,55 @@ class SystemConfigService:
|
|||
success = False
|
||||
return success
|
||||
|
||||
@classmethod
|
||||
def ensure_builtin_parameters(cls) -> None:
|
||||
"""确保内建系统参数存在,避免后台参数页缺少关键配置项。"""
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
for item in cls.BUILTIN_PARAMETERS:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sys_system_parameters
|
||||
(param_key, param_name, param_value, value_type, category, description, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
param_name = param_name
|
||||
""",
|
||||
(
|
||||
item["param_key"],
|
||||
item["param_name"],
|
||||
item["param_value"],
|
||||
item["value_type"],
|
||||
item["category"],
|
||||
item["description"],
|
||||
item["is_active"],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
except Exception as e:
|
||||
print(f"Error ensuring builtin parameters: {e}")
|
||||
|
||||
# 便捷方法:获取特定配置
|
||||
@classmethod
|
||||
def get_asr_vocabulary_id(cls) -> Optional[str]:
|
||||
"""获取ASR热词字典ID"""
|
||||
return cls.get_config(cls.ASR_VOCABULARY_ID)
|
||||
"""获取ASR热词字典ID — 优先从 audio_model_config.hot_word_group_id → hot_word_group.vocabulary_id"""
|
||||
audio_cfg = cls.get_active_audio_model_config("asr")
|
||||
if audio_cfg.get("vocabulary_id"):
|
||||
return audio_cfg["vocabulary_id"]
|
||||
return cls.get_config_attribute('audio_model', 'vocabulary_id')
|
||||
|
||||
# 声纹配置获取方法(直接使用通用方法)
|
||||
@classmethod
|
||||
def get_voiceprint_template(cls, default: str = "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。") -> str:
|
||||
"""获取声纹采集模版"""
|
||||
return cls.get_config_attribute('voiceprint', 'template_text', default)
|
||||
return cls.get_config_attribute('voiceprint_model', 'template_text', default)
|
||||
|
||||
@classmethod
|
||||
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
|
||||
"""获取声纹文件大小限制 (bytes), 默认5MB"""
|
||||
value = cls.get_config_attribute('voiceprint', 'voiceprint_max_size', default)
|
||||
value = cls.get_config_attribute('voiceprint_model', 'max_size_bytes', default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
|
|
@ -240,7 +580,7 @@ class SystemConfigService:
|
|||
@classmethod
|
||||
def get_voiceprint_duration(cls, default: int = 12) -> int:
|
||||
"""获取声纹采集最短时长 (秒)"""
|
||||
value = cls.get_config_attribute('voiceprint', 'duration_seconds', default)
|
||||
value = cls.get_config_attribute('voiceprint_model', 'duration_seconds', default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
|
|
@ -249,7 +589,7 @@ class SystemConfigService:
|
|||
@classmethod
|
||||
def get_voiceprint_sample_rate(cls, default: int = 16000) -> int:
|
||||
"""获取声纹采样率"""
|
||||
value = cls.get_config_attribute('voiceprint', 'sample_rate', default)
|
||||
value = cls.get_config_attribute('voiceprint_model', 'sample_rate', default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
|
|
@ -258,34 +598,98 @@ class SystemConfigService:
|
|||
@classmethod
|
||||
def get_voiceprint_channels(cls, default: int = 1) -> int:
|
||||
"""获取声纹通道数"""
|
||||
value = cls.get_config_attribute('voiceprint', 'channels', default)
|
||||
value = cls.get_config_attribute('voiceprint_model', 'channels', default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_timeline_pagesize(cls, default: int = 10) -> int:
|
||||
"""获取会议时间轴每页数量"""
|
||||
value = cls.get_config(cls.TIMELINE_PAGESIZE, str(default))
|
||||
def get_page_size(cls) -> int:
|
||||
"""获取系统通用分页数量。"""
|
||||
value = cls.get_config(cls.PAGE_SIZE)
|
||||
if value is None:
|
||||
raise RuntimeError("系统参数 page_size 缺失")
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
raise RuntimeError(f"系统参数 page_size 非法: {value!r}") from None
|
||||
|
||||
@classmethod
|
||||
def get_default_reset_password(cls, default: str = "111111") -> str:
|
||||
def get_default_reset_password(cls) -> str:
|
||||
"""获取默认重置密码"""
|
||||
return cls.get_config(cls.DEFAULT_RESET_PASSWORD, default)
|
||||
value = cls.get_config(cls.DEFAULT_RESET_PASSWORD)
|
||||
if value is None:
|
||||
raise RuntimeError("系统参数 default_reset_password 缺失")
|
||||
normalized = str(value).strip()
|
||||
if not normalized:
|
||||
raise RuntimeError("系统参数 default_reset_password 不能为空")
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def get_max_audio_size(cls, default: int = 100) -> int:
|
||||
def get_max_audio_size(cls) -> int:
|
||||
"""获取上传音频文件大小限制(MB)"""
|
||||
value = cls.get_config(cls.MAX_AUDIO_SIZE, str(default))
|
||||
value = cls.get_config(cls.MAX_AUDIO_SIZE)
|
||||
if value is None:
|
||||
raise RuntimeError("系统参数 max_audio_size 缺失")
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
raise RuntimeError(f"系统参数 max_audio_size 非法: {value!r}") from None
|
||||
|
||||
@classmethod
|
||||
def get_max_image_size(cls) -> int:
|
||||
"""获取上传图片大小限制(MB)"""
|
||||
value = cls.get_config(cls.MAX_IMAGE_SIZE)
|
||||
if value is None:
|
||||
raise RuntimeError("系统参数 max_image_size 缺失")
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise RuntimeError(f"系统参数 max_image_size 非法: {value!r}") from None
|
||||
|
||||
@classmethod
|
||||
def get_token_expire_days(cls) -> int:
|
||||
"""获取访问 token 过期时间(天)。"""
|
||||
value = cls.get_config(cls.TOKEN_EXPIRE_DAYS)
|
||||
if value is None:
|
||||
raise RuntimeError("系统参数 token_expire_days 缺失")
|
||||
try:
|
||||
normalized = int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise RuntimeError(f"系统参数 token_expire_days 非法: {value!r}") from None
|
||||
if normalized <= 0:
|
||||
raise RuntimeError(f"系统参数 token_expire_days 非法: {value!r}")
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def get_public_configs(cls) -> Dict[str, Any]:
|
||||
"""获取提供给前端初始化使用的公开参数。"""
|
||||
cls.ensure_builtin_parameters()
|
||||
public_configs = cls.get_configs_by_category(cls.PUBLIC_CATEGORY)
|
||||
required_keys = [
|
||||
cls.APP_NAME,
|
||||
cls.PAGE_SIZE,
|
||||
cls.MAX_AUDIO_SIZE,
|
||||
cls.MAX_IMAGE_SIZE,
|
||||
]
|
||||
missing_keys = [key for key in required_keys if str(public_configs.get(key) or "").strip() == ""]
|
||||
if missing_keys:
|
||||
raise RuntimeError(f"公开系统参数缺失: {', '.join(missing_keys)}")
|
||||
|
||||
page_size = cls.get_page_size()
|
||||
max_audio_size_mb = cls.get_max_audio_size()
|
||||
max_image_size_mb = cls.get_max_image_size()
|
||||
|
||||
return {
|
||||
"app_name": str(public_configs[cls.APP_NAME]).strip(),
|
||||
"page_size": str(page_size),
|
||||
"PAGE_SIZE": page_size,
|
||||
"max_audio_size": str(max_audio_size_mb),
|
||||
"MAX_FILE_SIZE": max_audio_size_mb * 1024 * 1024,
|
||||
"max_image_size": str(max_image_size_mb),
|
||||
"MAX_IMAGE_SIZE": max_image_size_mb * 1024 * 1024,
|
||||
}
|
||||
|
||||
# LLM模型配置获取方法(直接使用通用方法)
|
||||
@classmethod
|
||||
|
|
@ -319,3 +723,33 @@ class SystemConfigService:
|
|||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_llm_max_tokens(cls, default: int = 2048) -> int:
|
||||
"""获取LLM最大输出token"""
|
||||
value = cls.get_config_attribute('llm_model', 'max_tokens', default)
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def get_llm_system_prompt(cls, default: Optional[str] = None) -> Optional[str]:
|
||||
"""获取LLM系统提示词"""
|
||||
value = cls.get_config_attribute('llm_model', 'system_prompt', default)
|
||||
return value if isinstance(value, str) and value.strip() else default
|
||||
|
||||
@classmethod
|
||||
def get_llm_endpoint_url(cls, default: str = DEFAULT_LLM_ENDPOINT_URL) -> str:
|
||||
"""获取LLM服务Base API"""
|
||||
value = cls.get_config_attribute('llm_model', 'endpoint_url', default)
|
||||
return value if isinstance(value, str) and value.strip() else default
|
||||
|
||||
@classmethod
|
||||
def get_llm_api_key(cls, default: Optional[str] = None) -> Optional[str]:
|
||||
"""获取LLM服务API Key"""
|
||||
value = cls.get_config_attribute('llm_model', 'api_key', default)
|
||||
if value is None:
|
||||
return default
|
||||
value_str = str(value).strip()
|
||||
return value_str or default
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ class TerminalService:
|
|||
cu.caption as current_user_caption,
|
||||
dd.label_cn as terminal_type_name
|
||||
FROM terminals t
|
||||
LEFT JOIN users u ON t.created_by = u.user_id
|
||||
LEFT JOIN users cu ON t.current_user_id = cu.user_id
|
||||
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||
LEFT JOIN sys_users u ON t.created_by = u.user_id
|
||||
LEFT JOIN sys_users cu ON t.current_user_id = cu.user_id
|
||||
LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||
WHERE {where_clause}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
|
|
@ -75,8 +75,8 @@ class TerminalService:
|
|||
u.username as creator_username,
|
||||
dd.label_cn as terminal_type_name
|
||||
FROM terminals t
|
||||
LEFT JOIN users u ON t.created_by = u.user_id
|
||||
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||
LEFT JOIN sys_users u ON t.created_by = u.user_id
|
||||
LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||
WHERE t.id = %s
|
||||
"""
|
||||
cursor.execute(query, (terminal_id,))
|
||||
|
|
@ -105,14 +105,17 @@ class TerminalService:
|
|||
|
||||
query = """
|
||||
INSERT INTO terminals (
|
||||
imei, terminal_name, terminal_type, description, status, created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s)
|
||||
imei, terminal_name, terminal_type, description,
|
||||
firmware_version, mac_address, status, created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
cursor.execute(query, (
|
||||
terminal_data.imei,
|
||||
terminal_data.terminal_name,
|
||||
terminal_data.terminal_type,
|
||||
terminal_data.description,
|
||||
terminal_data.firmware_version,
|
||||
terminal_data.mac_address,
|
||||
terminal_data.status,
|
||||
user_id
|
||||
))
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
|
||||
用于解析音频文件的元数据信息,如时长、采样率、编码格式等
|
||||
"""
|
||||
|
||||
from tinytag import TinyTag
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def get_audio_duration(file_path: str) -> int:
|
||||
"""
|
||||
获取音频文件时长(秒)
|
||||
|
||||
使用TinyTag读取音频文件时长
|
||||
使用 ffprobe 读取音频时长
|
||||
|
||||
Args:
|
||||
file_path: 音频文件的完整路径
|
||||
|
|
@ -26,13 +27,33 @@ def get_audio_duration(file_path: str) -> int:
|
|||
- WAV (.wav)
|
||||
- OGG (.ogg)
|
||||
- FLAC (.flac)
|
||||
- 以及TinyTag支持的其他音频格式
|
||||
- 以及 ffprobe 支持的其他音频格式
|
||||
"""
|
||||
ffprobe_path = shutil.which("ffprobe")
|
||||
if not ffprobe_path:
|
||||
return 0
|
||||
|
||||
try:
|
||||
tag = TinyTag.get(file_path)
|
||||
if tag.duration and tag.duration > 0:
|
||||
return int(tag.duration)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
ffprobe_path,
|
||||
"-v",
|
||||
"error",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
str(file_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode == 0 and completed.stdout:
|
||||
payload = json.loads(completed.stdout)
|
||||
duration_value = (payload.get("format") or {}).get("duration")
|
||||
if duration_value:
|
||||
return int(float(duration_value))
|
||||
except Exception as e:
|
||||
print(f"获取音频时长失败 ({file_path}): {e}")
|
||||
print(f"ffprobe 获取音频时长失败 ({file_path}): {e}")
|
||||
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Core Application Framework
|
||||
fastapi
|
||||
uvicorn
|
||||
mcp
|
||||
|
||||
# Database & Cache
|
||||
mysql-connector-python
|
||||
|
|
@ -8,6 +9,7 @@ redis
|
|||
|
||||
# Services & External APIs
|
||||
requests
|
||||
httpx
|
||||
dashscope
|
||||
PyJWT
|
||||
qiniu
|
||||
|
|
@ -22,6 +24,4 @@ psutil
|
|||
# APK Parsing
|
||||
pyaxmlparser
|
||||
|
||||
# Audio Metadata
|
||||
tinytag
|
||||
python-dotenv
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
# 客户端管理 - 专用终端类型添加说明
|
||||
|
||||
## 概述
|
||||
|
||||
本次更新在客户端管理系统中添加了"专用终端"(terminal)大类型,支持 Android 专用终端和单片机(MCU)平台。
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 1. 修改表结构
|
||||
|
||||
执行 SQL 文件:`add_dedicated_terminal.sql`
|
||||
|
||||
```bash
|
||||
mysql -u [username] -p [database_name] < backend/sql/add_dedicated_terminal.sql
|
||||
```
|
||||
|
||||
**变更内容:**
|
||||
- 修改 `client_downloads` 表的 `platform_type` 枚举,添加 `terminal` 类型
|
||||
- 插入两条示例数据:
|
||||
- Android 专用终端(platform_type: `terminal`, platform_name: `android`)
|
||||
- 单片机固件(platform_type: `terminal`, platform_name: `mcu`)
|
||||
|
||||
### 2. 新的平台类型
|
||||
|
||||
| platform_type | platform_name | 说明 |
|
||||
|--------------|--------------|------|
|
||||
| terminal | android | Android 专用终端 |
|
||||
| terminal | mcu | 单片机(MCU)固件 |
|
||||
|
||||
## API 接口变更
|
||||
|
||||
### 1. 新增接口:通过平台类型和平台名称获取最新版本
|
||||
|
||||
**接口路径:** `GET /api/downloads/latest/by-platform`
|
||||
|
||||
**请求参数:**
|
||||
- `platform_type` (string, required): 平台类型 (mobile, desktop, terminal)
|
||||
- `platform_name` (string, required): 具体平台名称
|
||||
|
||||
**示例请求:**
|
||||
```bash
|
||||
# 获取 Android 专用终端最新版本
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=android"
|
||||
|
||||
# 获取单片机固件最新版本
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=mcu"
|
||||
```
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"code": "200",
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"id": 7,
|
||||
"platform_type": "terminal",
|
||||
"platform_name": "android",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1000,
|
||||
"download_url": "https://download.imeeting.com/terminals/android/iMeeting-Terminal-1.0.0.apk",
|
||||
"file_size": 25165824,
|
||||
"release_notes": "专用终端初始版本\n- 支持专用硬件集成\n- 优化的录音功能\n- 低功耗模式\n- 自动上传同步",
|
||||
"is_active": true,
|
||||
"is_latest": true,
|
||||
"min_system_version": "Android 5.0",
|
||||
"created_at": "2025-01-15T10:00:00",
|
||||
"updated_at": "2025-01-15T10:00:00",
|
||||
"created_by": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 更新接口:获取所有平台最新版本
|
||||
|
||||
**接口路径:** `GET /api/downloads/latest`
|
||||
|
||||
**变更:** 返回数据中新增 `terminal` 字段
|
||||
|
||||
**返回示例:**
|
||||
```json
|
||||
{
|
||||
"code": "200",
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"mobile": [...],
|
||||
"desktop": [...],
|
||||
"terminal": [
|
||||
{
|
||||
"id": 7,
|
||||
"platform_type": "terminal",
|
||||
"platform_name": "android",
|
||||
"version": "1.0.0",
|
||||
...
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"platform_type": "terminal",
|
||||
"platform_name": "mcu",
|
||||
"version": "1.0.0",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 已有接口说明
|
||||
|
||||
**原有接口:** `GET /api/downloads/{platform_name}/latest`
|
||||
|
||||
- 此接口标记为【已废弃】,建议使用新接口 `/downloads/latest/by-platform`
|
||||
- 原因:只通过 `platform_name` 查询可能产生歧义(如 mobile 的 android 和 terminal 的 android)
|
||||
- 保留此接口是为了向后兼容,但新开发应使用新接口
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1:专用终端设备版本检查
|
||||
|
||||
专用终端设备(如会议室固定录音设备、单片机硬件)启动时检查更新:
|
||||
|
||||
```javascript
|
||||
// Android 专用终端
|
||||
const response = await fetch(
|
||||
'/api/downloads/latest/by-platform?platform_type=terminal&platform_name=android'
|
||||
);
|
||||
const { data } = await response.json();
|
||||
|
||||
if (data.version_code > currentVersionCode) {
|
||||
// 发现新版本,提示更新
|
||||
showUpdateDialog(data);
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2:后台管理界面展示
|
||||
|
||||
管理员查看所有终端版本:
|
||||
|
||||
```javascript
|
||||
const response = await fetch('/api/downloads?platform_type=terminal');
|
||||
const { data } = await response.json();
|
||||
|
||||
// data.clients 包含所有 terminal 类型的客户端版本
|
||||
renderClientList(data.clients);
|
||||
```
|
||||
|
||||
### 场景 3:固件更新服务器
|
||||
|
||||
单片机设备定期轮询更新:
|
||||
|
||||
```c
|
||||
// MCU 固件代码示例
|
||||
char url[] = "http://api.imeeting.com/downloads/latest/by-platform?platform_type=terminal&platform_name=mcu";
|
||||
http_get(url, response_buffer);
|
||||
|
||||
// 解析 JSON 获取 download_url 和 version_code
|
||||
if (new_version > FIRMWARE_VERSION) {
|
||||
download_and_update(download_url);
|
||||
}
|
||||
```
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 数据库测试
|
||||
```sql
|
||||
-- 验证表结构修改
|
||||
DESCRIBE client_downloads;
|
||||
|
||||
-- 验证数据插入
|
||||
SELECT * FROM client_downloads WHERE platform_type = 'terminal';
|
||||
```
|
||||
|
||||
### 2. API 测试
|
||||
```bash
|
||||
# 测试新接口
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=android"
|
||||
|
||||
curl "http://localhost:8000/api/downloads/latest/by-platform?platform_type=terminal&platform_name=mcu"
|
||||
|
||||
# 测试获取所有最新版本
|
||||
curl "http://localhost:8000/api/downloads/latest"
|
||||
|
||||
# 测试列表接口
|
||||
curl "http://localhost:8000/api/downloads?platform_type=terminal"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **执行 SQL 前请备份数据库**
|
||||
2. **ENUM 类型修改**:ALTER TABLE 会修改表结构,请在低峰期执行
|
||||
3. **新接口优先**:建议所有新开发使用 `/downloads/latest/by-platform` 接口
|
||||
4. **版本管理**:上传新版本时记得设置 `is_latest=TRUE` 并将同平台旧版本设为 `FALSE`
|
||||
5. **platform_name 唯一性**:如果 mobile 和 terminal 都有 android,建议:
|
||||
- mobile 的保持 `android`
|
||||
- terminal 的改为 `android_terminal` 或其他区分名称
|
||||
- 或者始终使用新接口同时传递 platform_type 和 platform_name
|
||||
|
||||
## 文件清单
|
||||
|
||||
- `backend/sql/add_dedicated_terminal.sql` - 数据库迁移 SQL
|
||||
- `backend/app/api/endpoints/client_downloads.py` - API 接口代码
|
||||
- `backend/sql/README_terminal_update.md` - 本说明文档
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
-- 专用终端设备表
|
||||
CREATE TABLE IF NOT EXISTS `terminals` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`imei` varchar(64) NOT NULL COMMENT 'IMEI号(设备唯一标识)',
|
||||
`terminal_name` varchar(100) DEFAULT NULL COMMENT '终端名称/设备别名',
|
||||
`terminal_type` varchar(50) NOT NULL COMMENT '终端类型(关联dict_data.dict_code)',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '终端说明/备注',
|
||||
|
||||
-- 状态管理
|
||||
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '启用状态: 1-启用, 0-停用',
|
||||
`is_activated` tinyint(1) NOT NULL DEFAULT '0' COMMENT '激活状态: 1-已激活, 0-未激活',
|
||||
`activated_at` datetime DEFAULT NULL COMMENT '激活时间',
|
||||
|
||||
-- 运维监控字段
|
||||
`firmware_version` varchar(50) DEFAULT NULL COMMENT '当前固件版本',
|
||||
`last_online_at` datetime DEFAULT NULL COMMENT '最后在线/心跳时间',
|
||||
`ip_address` varchar(50) DEFAULT NULL COMMENT '最近一次连接IP',
|
||||
`mac_address` varchar(64) DEFAULT NULL COMMENT 'MAC地址',
|
||||
|
||||
-- 审计字段
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '录入时间',
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`created_by` int(11) DEFAULT NULL COMMENT '录入人ID',
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_imei` (`imei`),
|
||||
KEY `idx_terminal_type` (`terminal_type`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专用终端设备表';
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS `hot_words` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`text` VARCHAR(255) NOT NULL COMMENT '热词内容',
|
||||
`weight` INT NOT NULL DEFAULT 4 COMMENT '词汇权重 (1-10)',
|
||||
`lang` VARCHAR(20) NOT NULL DEFAULT 'zh' COMMENT '语言 (zh/en)',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 (1:启用, 0:禁用)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_text_lang` (`text`, `lang`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统语音识别热词表';
|
||||
|
||||
-- 预留存储 Vocabulary ID 的配置项(如果不想用字典表存储配置,也可以在系统配置表中增加)
|
||||
INSERT INTO `dict_data` (dict_type, dict_code, parent_code, label_cn, status)
|
||||
VALUES ('system_config', 'asr_vocabulary_id', 'ROOT', '阿里云ASR热词表ID', 1)
|
||||
ON DUPLICATE KEY UPDATE label_cn='阿里云ASR热词表ID';
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
-- ===================================================================
|
||||
-- 菜单权限系统数据库迁移脚本
|
||||
-- 创建日期: 2025-12-10
|
||||
-- 说明: 添加 menus 表和 role_menu_permissions 表,实现基于角色的菜单权限管理
|
||||
-- ===================================================================
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for menus
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `menus`;
|
||||
CREATE TABLE `menus` (
|
||||
`menu_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
|
||||
`menu_code` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '菜单代码(唯一标识)',
|
||||
`menu_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '菜单名称',
|
||||
`menu_icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单图标标识',
|
||||
`menu_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单URL/路由',
|
||||
`menu_type` enum('action','link','divider') COLLATE utf8mb4_unicode_ci DEFAULT 'action' COMMENT '菜单类型: action-操作/link-链接/divider-分隔符',
|
||||
`parent_id` int(11) DEFAULT NULL COMMENT '父菜单ID(用于层级菜单)',
|
||||
`sort_order` int(11) DEFAULT 0 COMMENT '排序顺序',
|
||||
`is_active` tinyint(1) DEFAULT 1 COMMENT '是否启用: 1-启用, 0-禁用',
|
||||
`description` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '菜单描述',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`menu_id`),
|
||||
UNIQUE KEY `uk_menu_code` (`menu_code`),
|
||||
KEY `idx_parent_id` (`parent_id`),
|
||||
KEY `idx_is_active` (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for role_menu_permissions
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `role_menu_permissions`;
|
||||
CREATE TABLE `role_menu_permissions` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '权限ID',
|
||||
`role_id` int(11) NOT NULL COMMENT '角色ID',
|
||||
`menu_id` int(11) NOT NULL COMMENT '菜单ID',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_role_menu` (`role_id`,`menu_id`),
|
||||
KEY `idx_role_id` (`role_id`),
|
||||
KEY `idx_menu_id` (`menu_id`),
|
||||
CONSTRAINT `fk_rmp_role_id` FOREIGN KEY (`role_id`) REFERENCES `roles` (`role_id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_rmp_menu_id` FOREIGN KEY (`menu_id`) REFERENCES `menus` (`menu_id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单权限映射表';
|
||||
|
||||
-- ----------------------------
|
||||
-- 初始化菜单数据(基于现有系统的下拉菜单)
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
|
||||
-- 用户菜单项
|
||||
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `sort_order`, `is_active`, `description`)
|
||||
VALUES
|
||||
('change_password', '修改密码', 'KeyRound', NULL, 'action', 1, 1, '用户修改自己的密码'),
|
||||
('prompt_management', '提示词仓库', 'BookText', '/prompt-management', 'link', 2, 1, '管理AI提示词模版'),
|
||||
('platform_admin', '平台管理', 'Shield', '/admin/management', 'link', 3, 1, '平台管理员后台'),
|
||||
('logout', '退出登录', 'LogOut', NULL, 'action', 99, 1, '退出当前账号');
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- 初始化角色权限数据
|
||||
-- 注意:角色表已存在,role_id=1为平台管理员,role_id=2为普通用户
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
|
||||
-- 平台管理员(role_id=1)拥有所有菜单权限
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 1, menu_id FROM `menus` WHERE is_active = 1;
|
||||
|
||||
-- 普通用户(role_id=2)拥有除"平台管理"外的所有菜单权限
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 2, menu_id FROM `menus` WHERE menu_code != 'platform_admin' AND is_active = 1;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- 查询验证
|
||||
-- ----------------------------
|
||||
-- 查看所有菜单
|
||||
-- SELECT * FROM menus ORDER BY sort_order;
|
||||
|
||||
-- 查看平台管理员的菜单权限
|
||||
-- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url
|
||||
-- FROM role_menu_permissions rmp
|
||||
-- JOIN roles r ON rmp.role_id = r.role_id
|
||||
-- JOIN menus m ON rmp.menu_id = m.menu_id
|
||||
-- WHERE r.role_id = 1
|
||||
-- ORDER BY m.sort_order;
|
||||
|
||||
-- 查看普通用户的菜单权限
|
||||
-- SELECT r.role_name, m.menu_name, m.menu_code, m.menu_url
|
||||
-- FROM role_menu_permissions rmp
|
||||
-- JOIN roles r ON rmp.role_id = r.role_id
|
||||
-- JOIN menus m ON rmp.menu_id = m.menu_id
|
||||
-- WHERE r.role_id = 2
|
||||
-- ORDER BY m.sort_order;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
-- 为 llm_tasks 表添加 prompt_id 列,用于支持自定义模版选择功能
|
||||
-- 执行日期:2025-12-08
|
||||
|
||||
ALTER TABLE `llm_tasks`
|
||||
ADD COLUMN `prompt_id` int(11) DEFAULT NULL COMMENT '提示词模版ID' AFTER `user_prompt`,
|
||||
ADD KEY `idx_prompt_id` (`prompt_id`);
|
||||
|
||||
-- 说明:
|
||||
-- 1. prompt_id 允许为 NULL,表示使用默认模版
|
||||
-- 2. 添加索引以优化查询性能
|
||||
-- 3. 不添加外键约束,因为 prompts 表中的记录可能被删除,我们希望保留历史任务记录
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
-- 添加 task_type 字典数据
|
||||
-- 用于会议任务和知识库任务的分类
|
||||
|
||||
INSERT INTO dict_data (dict_type, dict_code, parent_code, label_cn, label_en, sort_order, status, extension_attr) VALUES
|
||||
('task_type', 'MEETING_TASK', 'ROOT', '会议任务', 'Meeting Task', 1, 1, NULL),
|
||||
('task_type', 'KNOWLEDGE_TASK', 'ROOT', '知识库任务', 'Knowledge Task', 2, 1, NULL)
|
||||
ON DUPLICATE KEY UPDATE label_cn=VALUES(label_cn), label_en=VALUES(label_en);
|
||||
|
||||
-- 查看结果
|
||||
SELECT * FROM dict_data WHERE dict_type='task_type';
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
-- 客户端下载管理表
|
||||
CREATE TABLE IF NOT EXISTS client_downloads (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
|
||||
platform_type ENUM('mobile', 'desktop') NOT NULL COMMENT '平台类型:mobile-移动端, desktop-桌面端',
|
||||
platform_name VARCHAR(50) NOT NULL COMMENT '具体平台:ios, android, windows, mac_intel, mac_m, linux',
|
||||
version VARCHAR(50) NOT NULL COMMENT '版本号,如: 1.0.0',
|
||||
version_code INT NOT NULL DEFAULT 1 COMMENT '版本代码,用于版本比较',
|
||||
download_url TEXT NOT NULL COMMENT '下载链接',
|
||||
file_size BIGINT COMMENT '文件大小(字节)',
|
||||
release_notes TEXT COMMENT '更新说明',
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
is_latest BOOLEAN DEFAULT FALSE COMMENT '是否为最新版本',
|
||||
min_system_version VARCHAR(50) COMMENT '最低系统版本要求',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
created_by INT COMMENT '创建人ID',
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_platform (platform_type, platform_name),
|
||||
INDEX idx_version (version_code),
|
||||
INDEX idx_active (is_active, is_latest)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户端下载管理表';
|
||||
|
||||
-- 插入初始数据(示例版本)
|
||||
INSERT INTO client_downloads (
|
||||
platform_type,
|
||||
platform_name,
|
||||
version,
|
||||
version_code,
|
||||
download_url,
|
||||
file_size,
|
||||
release_notes,
|
||||
is_active,
|
||||
is_latest,
|
||||
min_system_version,
|
||||
created_by
|
||||
) VALUES
|
||||
-- iOS 客户端
|
||||
(
|
||||
'mobile',
|
||||
'ios',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://apps.apple.com/app/imeeting/id123456789',
|
||||
52428800, -- 50MB
|
||||
'初始版本发布
|
||||
- 支持会议录音
|
||||
- 支持实时转录
|
||||
- 支持会议摘要查看',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'iOS 13.0',
|
||||
1
|
||||
),
|
||||
|
||||
-- Android 客户端
|
||||
(
|
||||
'mobile',
|
||||
'android',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://play.google.com/store/apps/details?id=com.imeeting.app',
|
||||
45088768, -- 43MB
|
||||
'初始版本发布
|
||||
- 支持会议录音
|
||||
- 支持实时转录
|
||||
- 支持会议摘要查看',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Android 8.0',
|
||||
1
|
||||
),
|
||||
|
||||
-- Windows 客户端
|
||||
(
|
||||
'desktop',
|
||||
'windows',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/windows/iMeeting-1.0.0-Setup.exe',
|
||||
104857600, -- 100MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Windows 10 (64-bit)',
|
||||
1
|
||||
),
|
||||
|
||||
-- Mac Intel 客户端
|
||||
(
|
||||
'desktop',
|
||||
'mac_intel',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/mac/iMeeting-1.0.0-Intel.dmg',
|
||||
94371840, -- 90MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'macOS 10.15 Catalina',
|
||||
1
|
||||
),
|
||||
|
||||
-- Mac M系列 客户端
|
||||
(
|
||||
'desktop',
|
||||
'mac_m',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/mac/iMeeting-1.0.0-AppleSilicon.dmg',
|
||||
83886080, -- 80MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理
|
||||
- 原生支持Apple Silicon',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'macOS 11.0 Big Sur',
|
||||
1
|
||||
),
|
||||
|
||||
-- Linux 客户端
|
||||
(
|
||||
'desktop',
|
||||
'linux',
|
||||
'1.0.0',
|
||||
1000,
|
||||
'https://download.imeeting.com/clients/linux/iMeeting-1.0.0-x64.AppImage',
|
||||
98566144, -- 94MB
|
||||
'初始版本发布
|
||||
- 完整的会议管理功能
|
||||
- 高清音频录制
|
||||
- AI智能转录
|
||||
- 知识库管理
|
||||
- 支持主流Linux发行版',
|
||||
TRUE,
|
||||
TRUE,
|
||||
'Ubuntu 20.04 / Debian 10 / Fedora 32 或更高版本',
|
||||
1
|
||||
);
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
-- 客户端下载管理表
|
||||
-- 保留 platform_type 和 platform_name 字段以兼容旧终端
|
||||
-- 新增 platform_code 关联 dict_data 表的码表数据
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `client_downloads` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`platform_type` VARCHAR(50) NULL COMMENT '平台类型(兼容旧版:mobile, desktop, terminal)',
|
||||
`platform_name` VARCHAR(50) NULL COMMENT '平台名称(兼容旧版:ios, android, windows等)',
|
||||
`platform_code` VARCHAR(64) NOT NULL COMMENT '平台编码(关联 dict_data.dict_code)',
|
||||
`version` VARCHAR(50) NOT NULL COMMENT '版本号(如 1.0.0)',
|
||||
`version_code` INT NOT NULL COMMENT '版本号数值(用于版本比较)',
|
||||
`download_url` VARCHAR(512) NOT NULL COMMENT '下载链接',
|
||||
`file_size` BIGINT NULL COMMENT '文件大小(bytes)',
|
||||
`release_notes` TEXT NULL COMMENT '更新说明',
|
||||
`is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
|
||||
`is_latest` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为最新版本',
|
||||
`min_system_version` VARCHAR(50) NULL COMMENT '最低系统版本要求',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`created_by` INT NULL COMMENT '创建者用户ID',
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_platform_code` (`platform_code`),
|
||||
INDEX `idx_platform_type_name` (`platform_type`, `platform_name`),
|
||||
INDEX `idx_is_latest` (`is_latest`),
|
||||
INDEX `idx_is_active` (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端下载管理表';
|
||||
|
||||
-- 插入测试数据示例(包含新旧字段映射)
|
||||
-- 旧终端使用 platform_type + platform_name
|
||||
-- 新终端使用 platform_code
|
||||
-- INSERT INTO client_downloads (platform_type, platform_name, platform_code, version, version_code, download_url, file_size, release_notes, is_active, is_latest, min_system_version, created_by)
|
||||
-- VALUES
|
||||
-- ('desktop', 'windows', 'WIN', '1.0.0', 100, 'https://download.example.com/imeeting-win-1.0.0.exe', 52428800, '首个正式版本', TRUE, TRUE, 'Windows 10', 1),
|
||||
-- ('desktop', 'mac', 'MAC', '1.0.0', 100, 'https://download.example.com/imeeting-mac-1.0.0.dmg', 48234496, '首个正式版本', TRUE, TRUE, 'macOS 11.0', 1),
|
||||
-- ('mobile', 'ios', 'IOS', '1.0.0', 100, 'https://apps.apple.com/app/imeeting', 45088768, '首个正式版本', TRUE, TRUE, 'iOS 13.0', 1),
|
||||
-- ('mobile', 'android', 'ANDROID', '1.0.0', 100, 'https://download.example.com/imeeting-android-1.0.0.apk', 38797312, '首个正式版本', TRUE, TRUE, 'Android 8.0', 1);
|
||||
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
-- iMeeting 数据库初始化脚本 (MySQL 5.7 兼容)
|
||||
-- 基于 project.md v3
|
||||
|
||||
-- 设置数据库和字符集
|
||||
-- 请在使用前手动创建数据库: CREATE DATABASE imeeting CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
-- USE imeeting;
|
||||
|
||||
-- 删除已存在的表 (用于重新执行脚本)
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
DROP TABLE IF EXISTS `meeting_summaries`, `transcript_segments`, `audio_files`, `attachments`, `attendees`, `meetings`, `users`, `tags`;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 1. 创建表结构
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE `users` (
|
||||
`user_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`username` VARCHAR(50) UNIQUE NOT NULL,
|
||||
`caption` VARCHAR(50) NOT NULL,
|
||||
`email` VARCHAR(100) UNIQUE NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 会议表
|
||||
CREATE TABLE `meetings` (
|
||||
`meeting_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` INT, -- 会议创建者
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`meeting_time` TIMESTAMP NULL,
|
||||
`summary` TEXT, -- 以Markdown格式存储
|
||||
`tags` VARCHAR(1024) DEFAULT NULL, -- 以逗号分隔的标签字符串
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 参会人表 (关联用户)
|
||||
CREATE TABLE `attendees` (
|
||||
`attendee_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`user_id` INT,
|
||||
UNIQUE KEY `uk_meeting_user` (`meeting_id`, `user_id`) -- 确保同一用户在同一会议中只出现一次
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 会议材料附件表
|
||||
CREATE TABLE `attachments` (
|
||||
`attachment_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`file_name` VARCHAR(255) NOT NULL,
|
||||
`file_path` VARCHAR(512) NOT NULL, -- 存储路径或URL
|
||||
`file_type` VARCHAR(100),
|
||||
`uploaded_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 音频文件与处理任务表
|
||||
CREATE TABLE `audio_files` (
|
||||
`audio_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`file_name` VARCHAR(255),
|
||||
`file_path` VARCHAR(512) NOT NULL,
|
||||
`file_size` BIGINT DEFAULT NULL,
|
||||
`upload_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`processing_status` VARCHAR(20) DEFAULT 'uploaded', -- 'uploaded', 'processing', 'completed', 'failed'
|
||||
`error_message` TEXT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 转录任务表
|
||||
CREATE TABLE `transcript_tasks` (
|
||||
`task_id` VARCHAR(100) PRIMARY KEY,
|
||||
`paraformer_task_id` VARCHAR(100) DEFAULT NULL,
|
||||
`meeting_id` INT NOT NULL,
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||
`progress` INT DEFAULT 0, -- 0-100 进度百分比
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL,
|
||||
`error_message` TEXT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 转录内容表 (核心)
|
||||
CREATE TABLE `transcript_segments` (
|
||||
`segment_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`meeting_id` INT,
|
||||
`speaker_id` INT, -- 解析出来的人员ID
|
||||
`speaker_tag` VARCHAR(50) NOT NULL, -- e.g., "Speaker A", "李雷"
|
||||
`start_time_ms` INT NOT NULL, -- 音频开始时间(毫秒)
|
||||
`end_time_ms` INT NOT NULL, -- 音频结束时间(毫秒)
|
||||
`text_content` TEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 标签表 (用于标签快速检索和颜色管理)
|
||||
CREATE TABLE `tags` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`color` varchar(7) DEFAULT '#409EFF',
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `tag_name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 2. 插入测试数据
|
||||
|
||||
-- 插入用户 (4名)
|
||||
INSERT INTO `users` (`username`, `caption`, `email`, `password_hash`) VALUES
|
||||
('user1', 'alice', 'alice@example.com', 'hashed_password_1'),
|
||||
('user2', 'bob', 'bob@example.com', 'hashed_password_2'),
|
||||
('user3', 'charlie', 'charlie@example.com', 'hashed_password_3'),
|
||||
('user4', 'david', 'david@example.com', 'hashed_password_4');
|
||||
|
||||
-- 插入会议 (6条)
|
||||
INSERT INTO `meetings` (`user_id`, `title`, `meeting_time`, `summary`, `tags`) VALUES
|
||||
(1, 'Q3产品战略规划会', '2025-07-28 10:00:00', '# Q3产品战略规划会
|
||||
|
||||
## 核心议题
|
||||
- **目标**: 确定Q3主要产品迭代方向。
|
||||
- **讨论**: AI功能集成方案。
|
||||
|
||||
## 结论
|
||||
- 推进AI摘要功能开发。', '产品,重要'),
|
||||
(2, '“智慧大脑”项目技术评审', '2025-07-29 14:30:00', '技术方案已通过,部分细节待优化。', '技术'),
|
||||
(1, '营销团队周会', '2025-07-30 09:00:00', '回顾上周数据,制定本周计划。', '营销'),
|
||||
(3, '关于新版UI的设计评审', '2025-07-30 11:00:00', '## UI评审
|
||||
- **优点**: 简洁、现代。
|
||||
- **待办**: 调整登录页按钮颜色。', '设计'),
|
||||
(4, '年度财务报告初审', '2025-07-31 15:00:00', NULL, NULL),
|
||||
(2, '服务器架构升级讨论', '2025-08-01 16:00:00', '初步同意采用微服务架构。', '技术,重要');
|
||||
|
||||
-- 插入参会人
|
||||
-- 会议1: Alice, Bob, Charlie
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (1, 1), (1, 2), (1, 3);
|
||||
-- 会议2: Bob, David
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (2, 2), (2, 4);
|
||||
-- 会议3: Alice, Charlie
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (3, 1), (3, 3);
|
||||
-- 会议4: Charlie, Alice, David
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (4, 3), (4, 1), (4, 4);
|
||||
-- 会议5: David, Bob
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (5, 4), (5, 2);
|
||||
-- 会议6: Bob, Charlie, David
|
||||
INSERT INTO `attendees` (`meeting_id`, `user_id`) VALUES (6, 2), (6, 3), (6, 4);
|
||||
|
||||
-- 插入会议材料
|
||||
INSERT INTO `attachments` (`meeting_id`, `file_name`, `file_path`, `file_type`) VALUES
|
||||
(1, 'Q3产品规划.pptx', '/uploads/meeting_1/q3_plan.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'),
|
||||
(2, '技术方案V2.pdf', '/uploads/meeting_2/tech_spec_v2.pdf', 'application/pdf');
|
||||
|
||||
-- 插入音频文件记录
|
||||
INSERT INTO `audio_files` (`meeting_id`, `file_name`, `file_path`, `file_size`, `processing_status`) VALUES
|
||||
(1, 'meeting_1_audio.mp3', '/uploads/audio/1/meeting_1_audio.mp3', 15728640, 'completed'),
|
||||
(2, 'meeting_2_audio.wav', '/uploads/audio/2/meeting_2_audio.wav', 23456780, 'processing'),
|
||||
(3, 'meeting_3_audio.m4a', '/uploads/audio/3/meeting_3_audio.m4a', 18923456, 'uploaded'),
|
||||
(4, 'meeting_4_audio.mp3', '/uploads/audio/4/meeting_4_audio.mp3', 12345678, 'failed');
|
||||
|
||||
-- 插入转录任务记录
|
||||
INSERT INTO `transcript_tasks` (`task_id`, `meeting_id`, `status`, `progress`, `created_at`) VALUES
|
||||
('task-uuid-1', 1, 'completed', 100, '2025-07-28 10:05:00'),
|
||||
('task-uuid-2', 2, 'processing', 45, '2025-07-29 14:35:00'),
|
||||
('task-uuid-4', 4, 'failed', 0, '2025-07-30 11:05:00');
|
||||
|
||||
-- 插入转录内容 (为会议1)
|
||||
INSERT INTO `transcript_segments` (`meeting_id`, `speaker_id`, `speaker_tag`, `start_time_ms`, `end_time_ms`, `text_content`) VALUES
|
||||
(1, 0, '发言人 0', 5200, 9800, '好的,我们开始今天Q3的战略规划会。'),
|
||||
(1, 1, '发言人 1', 10100, 15500, '我先同步一下上个季度的数据,我们的用户增长了20%,主要来自于新推出的移动端。'),
|
||||
(1, 0, '发言人 0', 16000, 21300, '非常好。这个季度,我希望我们能重点讨论一下AI功能的集成,特别是会议摘要这部分。'),
|
||||
(1, 2, '发言人 2', 21800, 28000, '我同意,自动摘要可以极大地提升用户体验,我这边已经做了一些初步的技术调研。');
|
||||
|
||||
-- 插入标签
|
||||
INSERT INTO `tags` (`name`, `color`) VALUES
|
||||
('产品', '#409EFF'),
|
||||
('技术', '#67C23A'),
|
||||
('营销', '#E6A23C'),
|
||||
('设计', '#F56C6C'),
|
||||
('重要', '#909399');
|
||||
|
||||
-- 3. 添加外键约束
|
||||
ALTER TABLE `meetings` ADD CONSTRAINT `fk_meetings_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `attendees` ADD CONSTRAINT `fk_attendees_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `attendees` ADD CONSTRAINT `fk_attendees_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `attachments` ADD CONSTRAINT `fk_attachments_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `audio_files` ADD CONSTRAINT `fk_audio_files_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `transcript_tasks` ADD CONSTRAINT `fk_transcript_tasks_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
ALTER TABLE `transcript_segments` ADD CONSTRAINT `fk_transcript_segments_meeting` FOREIGN KEY (`meeting_id`) REFERENCES `meetings`(`meeting_id`) ON DELETE CASCADE;
|
||||
|
||||
-- 4. 添加索引优化查询性能
|
||||
-- audio_files 表索引
|
||||
ALTER TABLE `audio_files` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `audio_files` ADD INDEX `idx_task_id` (`task_id`);
|
||||
ALTER TABLE `audio_files` ADD INDEX `idx_processing_status` (`processing_status`);
|
||||
|
||||
-- transcript_tasks 表索引
|
||||
ALTER TABLE `transcript_tasks` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `transcript_tasks` ADD INDEX `idx_status` (`status`);
|
||||
ALTER TABLE `transcript_tasks` ADD INDEX `idx_created_at` (`created_at`);
|
||||
|
||||
-- transcript_segments 表索引
|
||||
ALTER TABLE `transcript_segments` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `transcript_segments` ADD INDEX `idx_speaker_id` (`speaker_id`);
|
||||
ALTER TABLE `transcript_segments` ADD INDEX `idx_start_time` (`start_time_ms`);
|
||||
|
||||
-- meetings 表索引
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_user_id` (`user_id`);
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_meeting_time` (`meeting_time`);
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_created_at` (`created_at`);
|
||||
ALTER TABLE `meetings` ADD INDEX `idx_tags` (`tags`(255));
|
||||
|
||||
-- attendees 表索引
|
||||
ALTER TABLE `attendees` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
ALTER TABLE `attendees` ADD INDEX `idx_user_id` (`user_id`);
|
||||
|
||||
-- attachments 表索引
|
||||
ALTER TABLE `attachments` ADD INDEX `idx_meeting_id` (`meeting_id`);
|
||||
|
||||
-- tags 表索引
|
||||
ALTER TABLE `tags` ADD INDEX `idx_name` (`name`);
|
||||
|
||||
-- 脚本结束
|
||||
SELECT '数据库初始化脚本 (MySQL) 执行完毕。';
|
||||
|
||||
-- Knowledge Base Tables
|
||||
CREATE TABLE IF NOT EXISTS `prompts` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(255) NOT NULL UNIQUE COMMENT '提示词名称,保持唯一以方便管理',
|
||||
`tags` VARCHAR(255) COMMENT '标签,用于分类和搜索,多个标签用逗号分隔',
|
||||
`content` TEXT NOT NULL COMMENT '完整的提示词内容',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
|
||||
) COMMENT='用于存储AI总结的提示词模板';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_bases` (
|
||||
`kb_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`title` VARCHAR(255) NOT NULL COMMENT '标题',
|
||||
`content` TEXT NULL COMMENT '生成的知识库内容 (Markdown格式)',
|
||||
`creator_id` INT NOT NULL COMMENT '创建者用户ID',
|
||||
`is_shared` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否为共享知识库',
|
||||
`source_meeting_ids` VARCHAR(255) NULL COMMENT '内容来源的会议ID列表 (逗号分隔)',
|
||||
`tags` VARCHAR(255) NULL COMMENT '逗号分隔的标签',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE
|
||||
) COMMENT='知识库条目表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `knowledge_base_tasks` (
|
||||
`task_id` VARCHAR(100) PRIMARY KEY COMMENT '业务任务唯一ID (UUID)',
|
||||
`user_id` INT NOT NULL COMMENT '发起任务的用户ID',
|
||||
`kb_id` INT NOT NULL COMMENT '关联的知识库条目ID',
|
||||
`user_prompt` TEXT NULL COMMENT '用户输入的提示词',
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending' COMMENT '任务状态',
|
||||
`progress` INT DEFAULT 0 COMMENT '任务进度百分比 (0-100)',
|
||||
`error_message` TEXT NULL COMMENT '任务失败时的错误信息',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`kb_id`) REFERENCES `knowledge_bases`(`kb_id`) ON DELETE CASCADE
|
||||
) COMMENT='知识库生成任务表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `prompt_config` (
|
||||
`config_id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`task_name` VARCHAR(100) UNIQUE NOT NULL COMMENT '任务名称',
|
||||
`prompt_id` INT NOT NULL COMMENT '关联的提示词模版ID',
|
||||
FOREIGN KEY (`prompt_id`) REFERENCES `prompts`(`id`)
|
||||
) COMMENT='提示词配置表';
|
||||
|
||||
-- Initial data for prompt_config
|
||||
INSERT INTO `prompt_config` (`task_name`, `prompt_id`) VALUES ('LLM_TASK', 1);
|
||||
INSERT INTO `prompt_config` (`task_name`, `prompt_id`) VALUES ('KNOWLEDGE_TASK', 2);
|
||||
|
||||
-- You might need to insert prompts with id=1 and id=2 into the `prompts` table for this to work.
|
||||
-- Example:
|
||||
-- INSERT INTO `prompts` (`id`, `name`, `content`) VALUES (1, 'Default Meeting Summary', 'Please summarize the following meeting transcript...');
|
||||
-- INSERT INTO `prompts` (`id`, `name`, `content`) VALUES (2, 'Default Knowledge Base Generation', 'Please generate a knowledge base article from the following text...');
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,643 @@
|
|||
-- iMeeting latest schema initialization script
|
||||
-- 用途:
|
||||
-- 1. 面向当前最新代码结构的全新部署
|
||||
-- 2. 不再兼容或保留旧 users / roles / menus 等历史结构
|
||||
-- 3. 仅负责建表,不写入基础角色/用户/菜单/参数等种子数据
|
||||
-- 4. 本文件即为全量建表入口,无需执行历史 migrations 目录脚本
|
||||
--
|
||||
-- 执行顺序:
|
||||
-- 1. 先执行本文件
|
||||
-- 2. 再执行 imeeting-seed-latest.sql
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- 兼容旧迁移中创建的 legacy views
|
||||
DROP VIEW IF EXISTS `users`;
|
||||
DROP VIEW IF EXISTS `roles`;
|
||||
DROP VIEW IF EXISTS `menus`;
|
||||
DROP VIEW IF EXISTS `role_menu_permissions`;
|
||||
DROP VIEW IF EXISTS `dict_data`;
|
||||
DROP VIEW IF EXISTS `system_parameters`;
|
||||
|
||||
-- 清理旧结构与当前结构
|
||||
DROP TABLE IF EXISTS `sys_role_menu_permissions`;
|
||||
DROP TABLE IF EXISTS `prompt_config`;
|
||||
DROP TABLE IF EXISTS `sys_user_mcp`;
|
||||
DROP TABLE IF EXISTS `user_logs`;
|
||||
DROP TABLE IF EXISTS `user_voiceprint`;
|
||||
DROP TABLE IF EXISTS `client_downloads`;
|
||||
DROP TABLE IF EXISTS `external_apps`;
|
||||
DROP TABLE IF EXISTS `terminals`;
|
||||
DROP TABLE IF EXISTS `knowledge_base_tasks`;
|
||||
DROP TABLE IF EXISTS `knowledge_bases`;
|
||||
DROP TABLE IF EXISTS `llm_tasks`;
|
||||
DROP TABLE IF EXISTS `transcript_segments`;
|
||||
DROP TABLE IF EXISTS `transcript_tasks`;
|
||||
DROP TABLE IF EXISTS `audio_files`;
|
||||
DROP TABLE IF EXISTS `attachments`;
|
||||
DROP TABLE IF EXISTS `attendees`;
|
||||
DROP TABLE IF EXISTS `meetings`;
|
||||
DROP TABLE IF EXISTS `tags`;
|
||||
DROP TABLE IF EXISTS `prompts`;
|
||||
DROP TABLE IF EXISTS `audio_model_config`;
|
||||
DROP TABLE IF EXISTS `llm_model_config`;
|
||||
DROP TABLE IF EXISTS `hot_word_item`;
|
||||
DROP TABLE IF EXISTS `hot_word_group`;
|
||||
DROP TABLE IF EXISTS `sys_system_parameters`;
|
||||
DROP TABLE IF EXISTS `sys_dict_data`;
|
||||
DROP TABLE IF EXISTS `sys_menus`;
|
||||
DROP TABLE IF EXISTS `sys_users`;
|
||||
DROP TABLE IF EXISTS `sys_roles`;
|
||||
|
||||
-- 历史阶段遗留表,一并清理
|
||||
DROP TABLE IF EXISTS `meeting_summaries`;
|
||||
DROP TABLE IF EXISTS `sys_user_prompt_config`;
|
||||
DROP TABLE IF EXISTS `ai_model_configs`;
|
||||
DROP TABLE IF EXISTS `ai_model_config`;
|
||||
DROP TABLE IF EXISTS `llm_model_configs`;
|
||||
DROP TABLE IF EXISTS `audio_model_configs`;
|
||||
DROP TABLE IF EXISTS `hot_words`;
|
||||
DROP TABLE IF EXISTS `system_parameters`;
|
||||
DROP TABLE IF EXISTS `dict_data`;
|
||||
DROP TABLE IF EXISTS `role_menu_permissions`;
|
||||
DROP TABLE IF EXISTS `menus`;
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
DROP TABLE IF EXISTS `roles`;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- =====================================================================
|
||||
-- 1. 基础系统表
|
||||
-- =====================================================================
|
||||
CREATE TABLE `sys_roles` (
|
||||
`role_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`role_name` VARCHAR(50) NOT NULL COMMENT '角色名称',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`role_id`),
|
||||
UNIQUE KEY `uk_sys_roles_name` (`role_name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统角色表';
|
||||
|
||||
CREATE TABLE `sys_users` (
|
||||
`user_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`username` VARCHAR(50) NOT NULL COMMENT '登录账号',
|
||||
`caption` VARCHAR(50) NOT NULL COMMENT '显示名称',
|
||||
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱,可为空',
|
||||
`avatar_url` VARCHAR(255) DEFAULT NULL COMMENT '头像地址',
|
||||
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希',
|
||||
`role_id` INT NOT NULL DEFAULT 2 COMMENT '角色ID',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`user_id`),
|
||||
UNIQUE KEY `uk_sys_users_username` (`username`),
|
||||
UNIQUE KEY `uk_sys_users_email` (`email`),
|
||||
KEY `idx_sys_users_role_id` (`role_id`),
|
||||
KEY `idx_sys_users_created_at` (`created_at`),
|
||||
CONSTRAINT `fk_sys_users_role_id`
|
||||
FOREIGN KEY (`role_id`) REFERENCES `sys_roles` (`role_id`)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统用户表';
|
||||
|
||||
CREATE TABLE `sys_menus` (
|
||||
`menu_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`menu_code` VARCHAR(50) NOT NULL COMMENT '菜单编码',
|
||||
`menu_name` VARCHAR(100) NOT NULL COMMENT '菜单名称',
|
||||
`menu_icon` VARCHAR(100) DEFAULT NULL COMMENT '菜单图标',
|
||||
`menu_url` VARCHAR(255) DEFAULT NULL COMMENT '前端路由',
|
||||
`menu_type` ENUM('action', 'link', 'divider') NOT NULL DEFAULT 'link' COMMENT '菜单类型',
|
||||
`parent_id` INT DEFAULT NULL COMMENT '父菜单ID',
|
||||
`menu_level` TINYINT NOT NULL DEFAULT 1 COMMENT '菜单层级(根节点为1)',
|
||||
`tree_path` VARCHAR(255) DEFAULT NULL COMMENT '树路径(如 /1/5)',
|
||||
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序号',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`is_visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否在侧边栏显示',
|
||||
`description` VARCHAR(255) DEFAULT NULL COMMENT '菜单说明',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`menu_id`),
|
||||
UNIQUE KEY `uk_sys_menus_code` (`menu_code`),
|
||||
KEY `idx_sys_menus_parent_id` (`parent_id`),
|
||||
KEY `idx_sys_menus_menu_level` (`menu_level`),
|
||||
KEY `idx_sys_menus_tree_path` (`tree_path`),
|
||||
KEY `idx_sys_menus_is_active` (`is_active`),
|
||||
KEY `idx_sys_menus_is_visible` (`is_visible`),
|
||||
CONSTRAINT `fk_sys_menus_parent_id`
|
||||
FOREIGN KEY (`parent_id`) REFERENCES `sys_menus` (`menu_id`)
|
||||
ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统菜单表';
|
||||
|
||||
CREATE TABLE `sys_dict_data` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`dict_type` VARCHAR(64) NOT NULL DEFAULT 'client_platform' COMMENT '字典类型',
|
||||
`dict_code` VARCHAR(64) NOT NULL COMMENT '业务编码',
|
||||
`parent_code` VARCHAR(64) NOT NULL DEFAULT 'ROOT' COMMENT '父级编码',
|
||||
`tree_path` VARCHAR(255) DEFAULT NULL COMMENT '树路径',
|
||||
`label_cn` VARCHAR(128) NOT NULL COMMENT '中文名称',
|
||||
`label_en` VARCHAR(128) DEFAULT NULL COMMENT '英文名称',
|
||||
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`extension_attr` LONGTEXT DEFAULT NULL COMMENT '扩展属性(JSON字符串)',
|
||||
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
|
||||
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_sys_dict_data_type_code` (`dict_type`, `dict_code`),
|
||||
KEY `idx_sys_dict_data_type_parent` (`dict_type`, `parent_code`),
|
||||
KEY `idx_sys_dict_data_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统字典数据表';
|
||||
|
||||
CREATE TABLE `sys_system_parameters` (
|
||||
`param_id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`param_key` VARCHAR(128) NOT NULL COMMENT '参数键',
|
||||
`param_name` VARCHAR(255) NOT NULL COMMENT '参数名称',
|
||||
`param_value` TEXT DEFAULT NULL COMMENT '参数值',
|
||||
`value_type` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '值类型',
|
||||
`category` VARCHAR(64) NOT NULL DEFAULT 'system' COMMENT '参数分类',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '参数说明',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`param_id`),
|
||||
UNIQUE KEY `uk_sys_system_parameters_key` (`param_key`),
|
||||
KEY `idx_sys_system_parameters_category` (`category`),
|
||||
KEY `idx_sys_system_parameters_active` (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统参数表';
|
||||
|
||||
CREATE TABLE `hot_word_group` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT '热词组名称',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '描述',
|
||||
`vocabulary_id` VARCHAR(255) DEFAULT NULL COMMENT '阿里云词表ID',
|
||||
`last_sync_time` DATETIME DEFAULT NULL COMMENT '最后同步时间',
|
||||
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_hot_word_group_name` (`name`),
|
||||
KEY `idx_hot_word_group_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='热词组主表';
|
||||
|
||||
CREATE TABLE `llm_model_config` (
|
||||
`config_id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`model_code` VARCHAR(128) NOT NULL COMMENT '模型编码',
|
||||
`model_name` VARCHAR(255) NOT NULL COMMENT '模型名称',
|
||||
`provider` VARCHAR(64) DEFAULT NULL COMMENT '供应商',
|
||||
`endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '模型接口地址',
|
||||
`api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥',
|
||||
`llm_model_name` VARCHAR(128) NOT NULL COMMENT '供应商模型名',
|
||||
`llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)',
|
||||
`llm_temperature` DECIMAL(5,2) NOT NULL DEFAULT 0.70 COMMENT 'temperature',
|
||||
`llm_top_p` DECIMAL(5,2) NOT NULL DEFAULT 0.90 COMMENT 'top_p',
|
||||
`llm_max_tokens` INT NOT NULL DEFAULT 2048 COMMENT '最大token数',
|
||||
`llm_system_prompt` TEXT DEFAULT NULL COMMENT '系统提示词',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '说明',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`config_id`),
|
||||
UNIQUE KEY `uk_llm_model_config_code` (`model_code`),
|
||||
KEY `idx_llm_model_config_active` (`is_active`),
|
||||
KEY `idx_llm_model_config_default` (`is_default`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='LLM模型配置表';
|
||||
|
||||
CREATE TABLE `audio_model_config` (
|
||||
`config_id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`model_code` VARCHAR(128) NOT NULL COMMENT '模型编码',
|
||||
`model_name` VARCHAR(255) NOT NULL COMMENT '模型名称',
|
||||
`audio_scene` VARCHAR(32) NOT NULL COMMENT 'asr / voiceprint',
|
||||
`provider` VARCHAR(64) DEFAULT NULL COMMENT '供应商',
|
||||
`endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址',
|
||||
`api_key` VARCHAR(512) DEFAULT NULL COMMENT '接口密钥',
|
||||
`request_timeout_seconds` INT NOT NULL DEFAULT 300 COMMENT '请求超时(秒)',
|
||||
`hot_word_group_id` INT DEFAULT NULL COMMENT '关联热词组',
|
||||
`extra_config` JSON DEFAULT NULL COMMENT '音频模型差异化配置',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '说明',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`config_id`),
|
||||
UNIQUE KEY `uk_audio_model_config_code` (`model_code`),
|
||||
KEY `idx_audio_model_config_scene` (`audio_scene`),
|
||||
KEY `idx_audio_model_config_active` (`is_active`),
|
||||
KEY `idx_audio_model_config_default` (`is_default`),
|
||||
KEY `idx_audio_model_config_hot_word_group_id` (`hot_word_group_id`),
|
||||
CONSTRAINT `fk_audio_model_config_hot_word_group_id`
|
||||
FOREIGN KEY (`hot_word_group_id`) REFERENCES `hot_word_group` (`id`)
|
||||
ON DELETE SET NULL ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='音频模型配置表';
|
||||
|
||||
CREATE TABLE `hot_word_item` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`group_id` INT NOT NULL COMMENT '热词组ID',
|
||||
`text` VARCHAR(255) NOT NULL COMMENT '热词内容',
|
||||
`weight` INT NOT NULL DEFAULT 4 COMMENT '权重',
|
||||
`lang` VARCHAR(20) NOT NULL DEFAULT 'zh' COMMENT '语言',
|
||||
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_hot_word_item_group_text` (`group_id`, `text`),
|
||||
KEY `idx_hot_word_item_group_id` (`group_id`),
|
||||
KEY `idx_hot_word_item_status` (`status`),
|
||||
CONSTRAINT `fk_hot_word_item_group_id`
|
||||
FOREIGN KEY (`group_id`) REFERENCES `hot_word_group` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='热词条目表';
|
||||
|
||||
CREATE TABLE `prompts` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(255) NOT NULL COMMENT '提示词名称',
|
||||
`task_type` ENUM('MEETING_TASK', 'KNOWLEDGE_TASK') NOT NULL COMMENT '任务类型',
|
||||
`content` LONGTEXT NOT NULL COMMENT '提示词内容',
|
||||
`desc` VARCHAR(500) DEFAULT NULL COMMENT '模版简介',
|
||||
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`creator_id` INT NOT NULL COMMENT '创建者',
|
||||
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否系统模版',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_prompts_creator_id` (`creator_id`),
|
||||
KEY `idx_prompts_task_scope_active` (`task_type`, `is_system`, `creator_id`, `is_active`, `is_default`),
|
||||
CONSTRAINT `fk_prompts_creator_id`
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提示词模版表';
|
||||
|
||||
CREATE TABLE `prompt_config` (
|
||||
`config_id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`task_type` ENUM('MEETING_TASK', 'KNOWLEDGE_TASK') NOT NULL COMMENT '任务类型',
|
||||
`prompt_id` INT NOT NULL COMMENT '模版ID',
|
||||
`is_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`config_id`),
|
||||
UNIQUE KEY `uk_prompt_config_user_task_prompt` (`user_id`, `task_type`, `prompt_id`),
|
||||
KEY `idx_prompt_config_user_task_order` (`user_id`, `task_type`, `sort_order`),
|
||||
KEY `idx_prompt_config_prompt_id` (`prompt_id`),
|
||||
CONSTRAINT `fk_prompt_config_user_id`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
CONSTRAINT `fk_prompt_config_prompt_id`
|
||||
FOREIGN KEY (`prompt_id`) REFERENCES `prompts` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户提示词配置表';
|
||||
|
||||
CREATE TABLE `sys_role_menu_permissions` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`role_id` INT NOT NULL COMMENT '角色ID',
|
||||
`menu_id` INT NOT NULL COMMENT '菜单ID',
|
||||
`granted_by` INT DEFAULT NULL COMMENT '授权人ID',
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '授权时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_sys_role_menu_permissions_role_menu` (`role_id`, `menu_id`),
|
||||
KEY `idx_sys_role_menu_permissions_role_id` (`role_id`),
|
||||
KEY `idx_sys_role_menu_permissions_menu_id` (`menu_id`),
|
||||
KEY `idx_sys_role_menu_permissions_granted_by` (`granted_by`),
|
||||
KEY `idx_sys_role_menu_permissions_granted_at` (`granted_at`),
|
||||
CONSTRAINT `fk_sys_role_menu_permissions_role_id`
|
||||
FOREIGN KEY (`role_id`) REFERENCES `sys_roles` (`role_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
CONSTRAINT `fk_sys_role_menu_permissions_menu_id`
|
||||
FOREIGN KEY (`menu_id`) REFERENCES `sys_menus` (`menu_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
CONSTRAINT `fk_sys_role_menu_permissions_granted_by`
|
||||
FOREIGN KEY (`granted_by`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE SET NULL ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色菜单授权表';
|
||||
|
||||
CREATE TABLE `sys_user_mcp` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`bot_id` VARCHAR(64) NOT NULL COMMENT '机器人ID',
|
||||
`bot_secret` VARCHAR(128) NOT NULL COMMENT '机器人密钥',
|
||||
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态',
|
||||
`last_used_at` DATETIME DEFAULT NULL COMMENT '最后使用时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_sys_user_mcp_user_id` (`user_id`),
|
||||
UNIQUE KEY `uk_sys_user_mcp_bot_id` (`bot_id`),
|
||||
KEY `idx_sys_user_mcp_status` (`status`),
|
||||
CONSTRAINT `fk_sys_user_mcp_user_id`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户MCP接入凭证表';
|
||||
|
||||
-- =====================================================================
|
||||
-- 2. 业务表
|
||||
-- =====================================================================
|
||||
CREATE TABLE `tags` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(255) NOT NULL COMMENT '标签名称',
|
||||
`color` VARCHAR(7) NOT NULL DEFAULT '#409EFF' COMMENT '标签颜色',
|
||||
`creator_id` INT DEFAULT NULL COMMENT '创建人',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tags_name` (`name`),
|
||||
KEY `idx_tags_creator_id` (`creator_id`),
|
||||
CONSTRAINT `fk_tags_creator_id`
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE SET NULL ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标签表';
|
||||
|
||||
CREATE TABLE `meetings` (
|
||||
`meeting_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '创建人',
|
||||
`title` VARCHAR(255) NOT NULL COMMENT '会议标题',
|
||||
`tags` VARCHAR(255) DEFAULT NULL COMMENT '逗号分隔标签',
|
||||
`meeting_time` DATETIME DEFAULT NULL COMMENT '会议时间',
|
||||
`access_password` VARCHAR(32) DEFAULT NULL COMMENT '访问密码',
|
||||
`prompt_id` INT DEFAULT 0 COMMENT '选用提示词ID,0表示未指定',
|
||||
`user_prompt` TEXT DEFAULT NULL COMMENT '用户额外提示',
|
||||
`summary` LONGTEXT DEFAULT NULL COMMENT '会议总结',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`meeting_id`),
|
||||
KEY `idx_meetings_user_id` (`user_id`),
|
||||
KEY `idx_meetings_meeting_time` (`meeting_time`),
|
||||
KEY `idx_meetings_created_at` (`created_at`),
|
||||
KEY `idx_meetings_prompt_id` (`prompt_id`),
|
||||
CONSTRAINT `fk_meetings_user_id`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会议表';
|
||||
|
||||
CREATE TABLE `attendees` (
|
||||
`attendee_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`meeting_id` INT NOT NULL COMMENT '会议ID',
|
||||
`user_id` INT NOT NULL COMMENT '参会用户ID',
|
||||
PRIMARY KEY (`attendee_id`),
|
||||
UNIQUE KEY `uk_attendees_meeting_user` (`meeting_id`, `user_id`),
|
||||
KEY `idx_attendees_meeting_id` (`meeting_id`),
|
||||
KEY `idx_attendees_user_id` (`user_id`),
|
||||
CONSTRAINT `fk_attendees_meeting_id`
|
||||
FOREIGN KEY (`meeting_id`) REFERENCES `meetings` (`meeting_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
CONSTRAINT `fk_attendees_user_id`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会议参会人表';
|
||||
|
||||
CREATE TABLE `attachments` (
|
||||
`attachment_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`meeting_id` INT NOT NULL COMMENT '会议ID',
|
||||
`file_name` VARCHAR(255) NOT NULL COMMENT '文件名',
|
||||
`file_path` VARCHAR(512) NOT NULL COMMENT '文件路径',
|
||||
`file_type` VARCHAR(100) DEFAULT NULL COMMENT '文件类型',
|
||||
`uploaded_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
|
||||
PRIMARY KEY (`attachment_id`),
|
||||
KEY `idx_attachments_meeting_id` (`meeting_id`),
|
||||
CONSTRAINT `fk_attachments_meeting_id`
|
||||
FOREIGN KEY (`meeting_id`) REFERENCES `meetings` (`meeting_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会议附件表';
|
||||
|
||||
CREATE TABLE `audio_files` (
|
||||
`audio_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`meeting_id` INT NOT NULL COMMENT '会议ID',
|
||||
`file_path` VARCHAR(512) NOT NULL COMMENT '音频相对路径',
|
||||
`file_name` VARCHAR(255) DEFAULT NULL COMMENT '原始文件名',
|
||||
`file_size` BIGINT DEFAULT NULL COMMENT '文件大小(字节)',
|
||||
`duration` DECIMAL(10,2) DEFAULT NULL COMMENT '音频时长(秒)',
|
||||
`upload_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
|
||||
`processing_status` VARCHAR(20) NOT NULL DEFAULT 'uploaded' COMMENT '处理状态',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`task_id` VARCHAR(255) DEFAULT NULL COMMENT '最新转写任务ID',
|
||||
PRIMARY KEY (`audio_id`),
|
||||
UNIQUE KEY `uk_audio_files_meeting_id` (`meeting_id`),
|
||||
KEY `idx_audio_files_task_id` (`task_id`),
|
||||
KEY `idx_audio_files_processing_status` (`processing_status`),
|
||||
CONSTRAINT `fk_audio_files_meeting_id`
|
||||
FOREIGN KEY (`meeting_id`) REFERENCES `meetings` (`meeting_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会议音频表';
|
||||
|
||||
CREATE TABLE `transcript_tasks` (
|
||||
`task_id` VARCHAR(100) NOT NULL COMMENT '业务任务ID',
|
||||
`paraformer_task_id` VARCHAR(100) DEFAULT NULL COMMENT '云端任务ID',
|
||||
`meeting_id` INT NOT NULL COMMENT '会议ID',
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending' COMMENT '任务状态',
|
||||
`progress` INT NOT NULL DEFAULT 0 COMMENT '任务进度',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`completed_at` DATETIME DEFAULT NULL COMMENT '完成时间',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
PRIMARY KEY (`task_id`),
|
||||
KEY `idx_transcript_tasks_meeting_id` (`meeting_id`),
|
||||
KEY `idx_transcript_tasks_status` (`status`),
|
||||
KEY `idx_transcript_tasks_created_at` (`created_at`),
|
||||
KEY `idx_transcript_tasks_paraformer_task_id` (`paraformer_task_id`),
|
||||
CONSTRAINT `fk_transcript_tasks_meeting_id`
|
||||
FOREIGN KEY (`meeting_id`) REFERENCES `meetings` (`meeting_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转写任务表';
|
||||
|
||||
CREATE TABLE `transcript_segments` (
|
||||
`segment_id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`meeting_id` INT NOT NULL COMMENT '会议ID',
|
||||
`speaker_id` INT NOT NULL DEFAULT 0 COMMENT '说话人编号',
|
||||
`speaker_tag` VARCHAR(50) DEFAULT NULL COMMENT '说话人标签',
|
||||
`start_time_ms` INT NOT NULL COMMENT '开始时间(毫秒)',
|
||||
`end_time_ms` INT NOT NULL COMMENT '结束时间(毫秒)',
|
||||
`text_content` LONGTEXT NOT NULL COMMENT '转写文本',
|
||||
PRIMARY KEY (`segment_id`),
|
||||
KEY `idx_transcript_segments_meeting_id` (`meeting_id`),
|
||||
KEY `idx_transcript_segments_speaker_id` (`speaker_id`),
|
||||
KEY `idx_transcript_segments_start_time_ms` (`start_time_ms`),
|
||||
CONSTRAINT `fk_transcript_segments_meeting_id`
|
||||
FOREIGN KEY (`meeting_id`) REFERENCES `meetings` (`meeting_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转写分段表';
|
||||
|
||||
CREATE TABLE `llm_tasks` (
|
||||
`task_id` VARCHAR(100) NOT NULL COMMENT '总结任务ID',
|
||||
`meeting_id` INT NOT NULL COMMENT '会议ID',
|
||||
`user_prompt` TEXT DEFAULT NULL COMMENT '用户额外提示',
|
||||
`prompt_id` INT DEFAULT NULL COMMENT '使用的模版ID',
|
||||
`model_code` VARCHAR(128) DEFAULT NULL COMMENT '使用的模型编码',
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending' COMMENT '任务状态',
|
||||
`progress` INT NOT NULL DEFAULT 0 COMMENT '任务进度',
|
||||
`result` LONGTEXT DEFAULT NULL COMMENT '总结结果',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`completed_at` DATETIME DEFAULT NULL COMMENT '完成时间',
|
||||
PRIMARY KEY (`task_id`),
|
||||
KEY `idx_llm_tasks_meeting_id` (`meeting_id`),
|
||||
KEY `idx_llm_tasks_status` (`status`),
|
||||
KEY `idx_llm_tasks_created_at` (`created_at`),
|
||||
KEY `idx_llm_tasks_prompt_id` (`prompt_id`),
|
||||
KEY `idx_llm_tasks_model_code` (`model_code`),
|
||||
CONSTRAINT `fk_llm_tasks_meeting_id`
|
||||
FOREIGN KEY (`meeting_id`) REFERENCES `meetings` (`meeting_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会议总结任务表';
|
||||
|
||||
CREATE TABLE `knowledge_bases` (
|
||||
`kb_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(255) NOT NULL COMMENT '知识标题',
|
||||
`prompt_id` INT DEFAULT 0 COMMENT '使用的模版ID',
|
||||
`user_prompt` TEXT DEFAULT NULL COMMENT '用户额外提示',
|
||||
`content` LONGTEXT DEFAULT NULL COMMENT '知识内容',
|
||||
`creator_id` INT NOT NULL COMMENT '创建人',
|
||||
`is_shared` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否共享',
|
||||
`source_meeting_ids` VARCHAR(255) DEFAULT NULL COMMENT '来源会议ID列表',
|
||||
`tags` VARCHAR(255) DEFAULT NULL COMMENT '标签',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`kb_id`),
|
||||
KEY `idx_knowledge_bases_creator_id` (`creator_id`),
|
||||
KEY `idx_knowledge_bases_prompt_id` (`prompt_id`),
|
||||
KEY `idx_knowledge_bases_updated_at` (`updated_at`),
|
||||
KEY `idx_knowledge_bases_is_shared` (`is_shared`),
|
||||
CONSTRAINT `fk_knowledge_bases_creator_id`
|
||||
FOREIGN KEY (`creator_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库表';
|
||||
|
||||
CREATE TABLE `knowledge_base_tasks` (
|
||||
`task_id` VARCHAR(100) NOT NULL COMMENT '知识库任务ID',
|
||||
`user_id` INT NOT NULL COMMENT '发起人',
|
||||
`kb_id` INT NOT NULL COMMENT '知识库ID',
|
||||
`prompt_id` INT DEFAULT NULL COMMENT '使用的模版ID',
|
||||
`user_prompt` TEXT DEFAULT NULL COMMENT '用户额外提示',
|
||||
`status` ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending' COMMENT '任务状态',
|
||||
`progress` INT NOT NULL DEFAULT 0 COMMENT '任务进度',
|
||||
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`completed_at` DATETIME DEFAULT NULL COMMENT '完成时间',
|
||||
PRIMARY KEY (`task_id`),
|
||||
KEY `idx_knowledge_base_tasks_user_id` (`user_id`),
|
||||
KEY `idx_knowledge_base_tasks_kb_id` (`kb_id`),
|
||||
KEY `idx_knowledge_base_tasks_status` (`status`),
|
||||
KEY `idx_knowledge_base_tasks_created_at` (`created_at`),
|
||||
CONSTRAINT `fk_knowledge_base_tasks_user_id`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||
CONSTRAINT `fk_knowledge_base_tasks_kb_id`
|
||||
FOREIGN KEY (`kb_id`) REFERENCES `knowledge_bases` (`kb_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库生成任务表';
|
||||
|
||||
CREATE TABLE `client_downloads` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`platform_type` VARCHAR(50) DEFAULT NULL COMMENT '平台类型(兼容字段)',
|
||||
`platform_name` VARCHAR(50) DEFAULT NULL COMMENT '平台名称(兼容字段)',
|
||||
`platform_code` VARCHAR(64) NOT NULL COMMENT '平台编码',
|
||||
`version` VARCHAR(50) NOT NULL COMMENT '版本号',
|
||||
`version_code` INT NOT NULL COMMENT '版本序号',
|
||||
`download_url` VARCHAR(512) NOT NULL COMMENT '下载地址',
|
||||
`file_size` BIGINT DEFAULT NULL COMMENT '文件大小',
|
||||
`release_notes` TEXT DEFAULT NULL COMMENT '更新说明',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`is_latest` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否最新',
|
||||
`min_system_version` VARCHAR(50) DEFAULT NULL COMMENT '最低系统版本',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`created_by` INT DEFAULT NULL COMMENT '创建人',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_client_downloads_platform_code` (`platform_code`),
|
||||
KEY `idx_client_downloads_platform_type_name` (`platform_type`, `platform_name`),
|
||||
KEY `idx_client_downloads_is_latest` (`is_latest`),
|
||||
KEY `idx_client_downloads_is_active` (`is_active`),
|
||||
KEY `idx_client_downloads_platform_code_version` (`platform_code`, `version_code`),
|
||||
CONSTRAINT `fk_client_downloads_created_by`
|
||||
FOREIGN KEY (`created_by`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE SET NULL ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户端下载管理表';
|
||||
|
||||
CREATE TABLE `external_apps` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`app_name` VARCHAR(100) NOT NULL COMMENT '应用名称',
|
||||
`app_type` ENUM('native', 'web') NOT NULL COMMENT '应用类型',
|
||||
`app_info` TEXT DEFAULT NULL COMMENT '应用信息(JSON字符串)',
|
||||
`icon_url` TEXT DEFAULT NULL COMMENT '图标地址',
|
||||
`description` TEXT DEFAULT NULL COMMENT '应用说明',
|
||||
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`created_by` INT DEFAULT NULL COMMENT '创建人',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_external_apps_type` (`app_type`),
|
||||
KEY `idx_external_apps_active` (`is_active`),
|
||||
KEY `idx_external_apps_sort_order` (`sort_order`),
|
||||
KEY `idx_external_apps_type_active` (`app_type`, `is_active`),
|
||||
CONSTRAINT `fk_external_apps_created_by`
|
||||
FOREIGN KEY (`created_by`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE SET NULL ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部应用管理表';
|
||||
|
||||
CREATE TABLE `terminals` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`imei` VARCHAR(64) NOT NULL COMMENT '终端唯一标识',
|
||||
`terminal_name` VARCHAR(100) DEFAULT NULL COMMENT '终端名称',
|
||||
`terminal_type` VARCHAR(50) NOT NULL COMMENT '终端类型编码',
|
||||
`description` VARCHAR(500) DEFAULT NULL COMMENT '说明',
|
||||
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用状态',
|
||||
`is_activated` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '激活状态',
|
||||
`activated_at` DATETIME DEFAULT NULL COMMENT '激活时间',
|
||||
`firmware_version` VARCHAR(50) DEFAULT NULL COMMENT '固件版本',
|
||||
`last_online_at` DATETIME DEFAULT NULL COMMENT '最后在线时间',
|
||||
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT '最近在线IP',
|
||||
`mac_address` VARCHAR(64) DEFAULT NULL COMMENT 'MAC地址',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`created_by` INT DEFAULT NULL COMMENT '创建人',
|
||||
`current_user_id` INT DEFAULT NULL COMMENT '当前绑定用户',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_terminals_imei` (`imei`),
|
||||
KEY `idx_terminals_terminal_type` (`terminal_type`),
|
||||
KEY `idx_terminals_status` (`status`),
|
||||
KEY `idx_terminals_created_by` (`created_by`),
|
||||
KEY `idx_terminals_current_user_id` (`current_user_id`),
|
||||
KEY `idx_terminals_last_online_at` (`last_online_at`),
|
||||
CONSTRAINT `fk_terminals_created_by`
|
||||
FOREIGN KEY (`created_by`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE SET NULL ON UPDATE RESTRICT,
|
||||
CONSTRAINT `fk_terminals_current_user_id`
|
||||
FOREIGN KEY (`current_user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE SET NULL ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='专用终端设备表';
|
||||
|
||||
CREATE TABLE `user_logs` (
|
||||
`log_id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`action_type` VARCHAR(50) NOT NULL COMMENT '行为类型',
|
||||
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
|
||||
`user_agent` TEXT DEFAULT NULL COMMENT 'User-Agent',
|
||||
`metadata` LONGTEXT DEFAULT NULL COMMENT '扩展元数据(JSON字符串)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`log_id`),
|
||||
KEY `idx_user_logs_user_id` (`user_id`),
|
||||
KEY `idx_user_logs_action_type` (`action_type`),
|
||||
KEY `idx_user_logs_created_at` (`created_at`),
|
||||
KEY `idx_user_logs_user_action` (`user_id`, `action_type`),
|
||||
CONSTRAINT `fk_user_logs_user_id`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为日志表';
|
||||
|
||||
CREATE TABLE `user_voiceprint` (
|
||||
`vp_id` INT NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`file_path` VARCHAR(255) NOT NULL COMMENT '声纹文件路径',
|
||||
`file_size` BIGINT DEFAULT NULL COMMENT '文件大小',
|
||||
`duration_seconds` DECIMAL(5,2) DEFAULT 10.00 COMMENT '音频时长',
|
||||
`vector_data` LONGTEXT DEFAULT NULL COMMENT '声纹向量(JSON字符串)',
|
||||
`collected_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '采集时间',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`vp_id`),
|
||||
UNIQUE KEY `uk_user_voiceprint_user_id` (`user_id`),
|
||||
KEY `idx_user_voiceprint_collected_at` (`collected_at`),
|
||||
CONSTRAINT `fk_user_voiceprint_user_id`
|
||||
FOREIGN KEY (`user_id`) REFERENCES `sys_users` (`user_id`)
|
||||
ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户声纹表';
|
||||
|
||||
SELECT 'imeeting-schema-latest.sql executed successfully' AS `message`;
|
||||
|
|
@ -0,0 +1,796 @@
|
|||
-- iMeeting latest seed initialization script
|
||||
-- 用途:
|
||||
-- 1. 面向“已执行最新全量建表脚本”的全新部署
|
||||
-- 2. 仅初始化系统基础数据,不包含历史会议、附件、任务、知识库等业务数据
|
||||
-- 3. 尽量支持重复执行(幂等),便于新库初始化或回放
|
||||
--
|
||||
-- 重要前提:
|
||||
-- 1. 已先执行 imeeting-schema-latest.sql
|
||||
-- 2. 当前库已存在以下最新结构:
|
||||
-- sys_roles / sys_users / sys_menus / sys_role_menu_permissions
|
||||
-- prompts(desc,is_system) / prompt_config
|
||||
-- sys_system_parameters / llm_model_config / audio_model_config
|
||||
-- hot_word_group / hot_word_item / sys_dict_data
|
||||
--
|
||||
-- 默认账号:
|
||||
-- admin / admin123
|
||||
-- demo / 123456
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 模型 API Key 默认留空,优先走环境变量配置
|
||||
-- 2. ASR 默认绑定“默认热词组”,首次执行后仍可在后台继续同步词表
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- =====================================================================
|
||||
-- 1. 基础角色
|
||||
-- =====================================================================
|
||||
INSERT INTO `sys_roles` (`role_id`, `role_name`, `created_at`)
|
||||
VALUES
|
||||
(1, '平台管理员', NOW()),
|
||||
(2, '普通用户', NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`role_name` = VALUES(`role_name`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 2. 基础用户
|
||||
-- 密码说明:
|
||||
-- admin -> admin123
|
||||
-- demo -> 123456
|
||||
-- =====================================================================
|
||||
INSERT INTO `sys_users`
|
||||
(`user_id`, `username`, `caption`, `email`, `avatar_url`, `password_hash`, `role_id`, `created_at`)
|
||||
VALUES
|
||||
(1, 'admin', '系统管理员', 'admin@imeeting.local', NULL, '240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9', 1, NOW()),
|
||||
(2, 'demo', '演示用户', 'demo@imeeting.local', NULL, '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', 2, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`username` = VALUES(`username`),
|
||||
`caption` = VALUES(`caption`),
|
||||
`email` = VALUES(`email`),
|
||||
`avatar_url` = VALUES(`avatar_url`),
|
||||
`password_hash` = VALUES(`password_hash`),
|
||||
`role_id` = VALUES(`role_id`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 3. 系统默认提示词模版
|
||||
-- =====================================================================
|
||||
INSERT INTO `prompts`
|
||||
(`id`, `name`, `task_type`, `content`, `desc`, `is_default`, `is_active`, `creator_id`, `is_system`, `created_at`)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
'系统默认会议总结',
|
||||
'MEETING_TASK',
|
||||
'你是一名专业的会议纪要助手。请基于会议转写内容输出结构化 Markdown,总结以下内容:
|
||||
|
||||
## 会议概览
|
||||
- 说明会议主题、时间、参与方、目标
|
||||
|
||||
## 核心讨论
|
||||
- 按议题归纳讨论重点,不按逐句流水账整理
|
||||
|
||||
## 决策事项
|
||||
- 明确已经形成共识的决定、口径与结论
|
||||
|
||||
## 待办事项
|
||||
- 使用表格列出事项、责任人、时间要求、当前状态
|
||||
|
||||
## 风险与建议
|
||||
- 提醒未决问题、依赖项、潜在风险,并给出后续建议
|
||||
|
||||
输出要求:
|
||||
1. 仅基于原文,不臆测未提及内容
|
||||
2. 信息缺失时明确标注“未提及”
|
||||
3. 语言简洁、可直接归档共享',
|
||||
'会议转写默认总结模板',
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
2,
|
||||
'系统默认知识整理',
|
||||
'KNOWLEDGE_TASK',
|
||||
'你是一名专业的知识整理助手。请基于输入材料生成一篇适合归档到知识库的 Markdown 文档,包含以下部分:
|
||||
|
||||
## 标题
|
||||
- 产出准确、可检索的标题
|
||||
|
||||
## 摘要
|
||||
- 用 3 至 5 句话概括核心内容
|
||||
|
||||
## 关键事实
|
||||
- 提炼重要信息、规则、约束和背景
|
||||
|
||||
## 操作步骤或处理方案
|
||||
- 如果材料中包含流程或做法,请按步骤梳理
|
||||
|
||||
## 风险与注意事项
|
||||
- 标记边界条件、限制项和容易出错的点
|
||||
|
||||
## 相关名词与标签
|
||||
- 提炼适合检索和分类的关键词
|
||||
|
||||
输出要求:
|
||||
1. 保持事实准确,避免编造
|
||||
2. 优先结构化、可复用、可沉淀
|
||||
3. 文风专业、简洁、便于知识检索',
|
||||
'知识库整理默认模板',
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`task_type` = VALUES(`task_type`),
|
||||
`content` = VALUES(`content`),
|
||||
`desc` = VALUES(`desc`),
|
||||
`is_default` = VALUES(`is_default`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`creator_id` = VALUES(`creator_id`),
|
||||
`is_system` = VALUES(`is_system`);
|
||||
|
||||
-- 为初始化账号补充默认模版启用配置
|
||||
INSERT INTO `prompt_config`
|
||||
(`user_id`, `task_type`, `prompt_id`, `is_enabled`, `sort_order`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1, 'MEETING_TASK', 1, 1, 1, NOW(), NOW()),
|
||||
(1, 'KNOWLEDGE_TASK', 2, 1, 1, NOW(), NOW()),
|
||||
(2, 'MEETING_TASK', 1, 1, 1, NOW(), NOW()),
|
||||
(2, 'KNOWLEDGE_TASK', 2, 1, 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`is_enabled` = VALUES(`is_enabled`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 4. 系统参数
|
||||
-- =====================================================================
|
||||
INSERT INTO `sys_system_parameters`
|
||||
(`param_key`, `param_name`, `param_value`, `value_type`, `category`, `description`, `is_active`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
('token_expire_days', 'Token过期时间', '7', 'number', 'system', '控制登录 token 的过期时间,单位:天。', 1, NOW(), NOW()),
|
||||
('default_reset_password', '默认重置密码', '123456', 'string', 'system', '管理员重置用户密码时使用的默认密码。', 1, NOW(), NOW()),
|
||||
('page_size', '系统分页大小', '10', 'number', 'public', '系统通用分页数量。', 1, NOW(), NOW()),
|
||||
('max_audio_size', '音频上传大小限制', '100', 'number', 'public', '音频上传大小限制,单位:MB。', 1, NOW(), NOW()),
|
||||
('max_image_size', '图片上传大小限制', '10', 'number', 'public', '图片上传大小限制,单位:MB。', 1, NOW(), NOW()),
|
||||
('app_name', '系统名称', 'iMeeting', 'string', 'public', '前端应用标题。', 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`param_name` = VALUES(`param_name`),
|
||||
`param_value` = VALUES(`param_value`),
|
||||
`value_type` = VALUES(`value_type`),
|
||||
`category` = VALUES(`category`),
|
||||
`description` = VALUES(`description`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 5. 默认热词组与热词
|
||||
-- =====================================================================
|
||||
INSERT INTO `hot_word_group` (`name`, `description`, `status`, `create_time`, `update_time`)
|
||||
SELECT '默认热词组', '系统初始化热词组,供默认 ASR 模型绑定使用。', 1, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `hot_word_group` WHERE `name` = '默认热词组'
|
||||
);
|
||||
|
||||
UPDATE `hot_word_group`
|
||||
SET
|
||||
`description` = '系统初始化热词组,供默认 ASR 模型绑定使用。',
|
||||
`status` = 1,
|
||||
`update_time` = NOW()
|
||||
WHERE `name` = '默认热词组';
|
||||
|
||||
SET @default_hot_word_group_id := (
|
||||
SELECT `id`
|
||||
FROM `hot_word_group`
|
||||
WHERE `name` = '默认热词组'
|
||||
ORDER BY `id`
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO `hot_word_item`
|
||||
(`group_id`, `text`, `weight`, `lang`, `status`, `create_time`, `update_time`)
|
||||
SELECT @default_hot_word_group_id, 'iMeeting', 8, 'zh', 1, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @default_hot_word_group_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`weight` = VALUES(`weight`),
|
||||
`lang` = VALUES(`lang`),
|
||||
`status` = VALUES(`status`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
INSERT INTO `hot_word_item`
|
||||
(`group_id`, `text`, `weight`, `lang`, `status`, `create_time`, `update_time`)
|
||||
SELECT @default_hot_word_group_id, '会议纪要', 7, 'zh', 1, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @default_hot_word_group_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`weight` = VALUES(`weight`),
|
||||
`lang` = VALUES(`lang`),
|
||||
`status` = VALUES(`status`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
INSERT INTO `hot_word_item`
|
||||
(`group_id`, `text`, `weight`, `lang`, `status`, `create_time`, `update_time`)
|
||||
SELECT @default_hot_word_group_id, '语音转写', 6, 'zh', 1, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @default_hot_word_group_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`weight` = VALUES(`weight`),
|
||||
`lang` = VALUES(`lang`),
|
||||
`status` = VALUES(`status`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 6. 默认模型配置
|
||||
-- =====================================================================
|
||||
INSERT INTO `llm_model_config`
|
||||
(`model_code`, `model_name`, `provider`, `endpoint_url`, `api_key`, `llm_model_name`,
|
||||
`llm_timeout`, `llm_temperature`, `llm_top_p`, `llm_max_tokens`, `llm_system_prompt`,
|
||||
`description`, `is_active`, `is_default`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(
|
||||
'llm_model',
|
||||
'默认文本模型',
|
||||
'dashscope',
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
NULL,
|
||||
'qwen-plus',
|
||||
120,
|
||||
0.70,
|
||||
0.90,
|
||||
2048,
|
||||
'你是一名专业的会议与知识整理助手,请基于输入内容给出准确、结构化、可复用的输出。',
|
||||
'系统初始化的默认 LLM 模型配置,API Key 优先使用环境变量。',
|
||||
1,
|
||||
1,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`model_name` = VALUES(`model_name`),
|
||||
`provider` = VALUES(`provider`),
|
||||
`endpoint_url` = VALUES(`endpoint_url`),
|
||||
`api_key` = VALUES(`api_key`),
|
||||
`llm_model_name` = VALUES(`llm_model_name`),
|
||||
`llm_timeout` = VALUES(`llm_timeout`),
|
||||
`llm_temperature` = VALUES(`llm_temperature`),
|
||||
`llm_top_p` = VALUES(`llm_top_p`),
|
||||
`llm_max_tokens` = VALUES(`llm_max_tokens`),
|
||||
`llm_system_prompt` = VALUES(`llm_system_prompt`),
|
||||
`description` = VALUES(`description`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_default` = VALUES(`is_default`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `audio_model_config`
|
||||
(`model_code`, `model_name`, `audio_scene`, `provider`, `endpoint_url`, `api_key`,
|
||||
`request_timeout_seconds`, `hot_word_group_id`, `extra_config`, `description`,
|
||||
`is_active`, `is_default`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'audio_model',
|
||||
'默认语音识别模型',
|
||||
'asr',
|
||||
'dashscope',
|
||||
'https://dashscope.aliyuncs.com',
|
||||
NULL,
|
||||
300,
|
||||
g.`id`,
|
||||
JSON_OBJECT(
|
||||
'model', 'paraformer-v2',
|
||||
'vocabulary_id', g.`vocabulary_id`,
|
||||
'speaker_count', 10,
|
||||
'language_hints', JSON_ARRAY('zh', 'en'),
|
||||
'disfluency_removal_enabled', TRUE,
|
||||
'diarization_enabled', TRUE
|
||||
),
|
||||
'系统初始化的默认 ASR 模型配置。',
|
||||
1,
|
||||
1,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM `hot_word_group` g
|
||||
WHERE g.`id` = @default_hot_word_group_id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`model_name` = VALUES(`model_name`),
|
||||
`audio_scene` = VALUES(`audio_scene`),
|
||||
`provider` = VALUES(`provider`),
|
||||
`endpoint_url` = VALUES(`endpoint_url`),
|
||||
`api_key` = VALUES(`api_key`),
|
||||
`request_timeout_seconds` = VALUES(`request_timeout_seconds`),
|
||||
`hot_word_group_id` = VALUES(`hot_word_group_id`),
|
||||
`extra_config` = VALUES(`extra_config`),
|
||||
`description` = VALUES(`description`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_default` = VALUES(`is_default`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `audio_model_config`
|
||||
(`model_code`, `model_name`, `audio_scene`, `provider`, `endpoint_url`, `api_key`,
|
||||
`request_timeout_seconds`, `hot_word_group_id`, `extra_config`, `description`,
|
||||
`is_active`, `is_default`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(
|
||||
'voiceprint_model',
|
||||
'默认声纹配置',
|
||||
'voiceprint',
|
||||
'funasr',
|
||||
NULL,
|
||||
NULL,
|
||||
120,
|
||||
NULL,
|
||||
JSON_OBJECT(
|
||||
'template_text', '我正在进行声纹采集,这段语音将用于身份识别和验证。声纹技术能够准确识别每个人独特的声音特征。',
|
||||
'duration_seconds', 12,
|
||||
'sample_rate', 16000,
|
||||
'channels', 1,
|
||||
'max_size_bytes', 5242880
|
||||
),
|
||||
'系统初始化的默认声纹采集配置。',
|
||||
1,
|
||||
1,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`model_name` = VALUES(`model_name`),
|
||||
`audio_scene` = VALUES(`audio_scene`),
|
||||
`provider` = VALUES(`provider`),
|
||||
`endpoint_url` = VALUES(`endpoint_url`),
|
||||
`api_key` = VALUES(`api_key`),
|
||||
`request_timeout_seconds` = VALUES(`request_timeout_seconds`),
|
||||
`hot_word_group_id` = VALUES(`hot_word_group_id`),
|
||||
`extra_config` = VALUES(`extra_config`),
|
||||
`description` = VALUES(`description`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_default` = VALUES(`is_default`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 7. 基础字典数据
|
||||
-- =====================================================================
|
||||
INSERT INTO `sys_dict_data`
|
||||
(`dict_type`, `dict_code`, `parent_code`, `tree_path`, `label_cn`, `label_en`, `sort_order`,
|
||||
`extension_attr`, `is_default`, `status`, `create_time`, `update_time`)
|
||||
VALUES
|
||||
('client_platform', 'DESKTOP', 'ROOT', NULL, '桌面端', 'Desktop', 1, JSON_OBJECT('icon', 'monitor'), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'MOBILE', 'ROOT', NULL, '移动端', 'Mobile', 2, JSON_OBJECT('icon', 'phone'), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'TERMINAL', 'ROOT', NULL, '专用终端', 'Terminal', 3, JSON_OBJECT('icon', 'router'), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'WIN', 'DESKTOP', NULL, 'Windows', 'Windows', 1, JSON_OBJECT('suffix', '.exe', 'arch_support', JSON_ARRAY('x86', 'x64')), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'MAC', 'DESKTOP', NULL, 'macOS', 'macOS', 2, JSON_OBJECT('suffix', '.dmg', 'arch_support', JSON_ARRAY('x64', 'arm64')), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'LINUX', 'DESKTOP', NULL, 'Linux', 'Linux', 3, JSON_OBJECT('suffix', '.deb', 'arch_support', JSON_ARRAY('x64', 'arm64')), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'IOS', 'MOBILE', NULL, '苹果iOS', 'iOS', 1, JSON_OBJECT('suffix', '.ipa', 'store_link', TRUE), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'ANDROID', 'MOBILE', NULL, '安卓', 'Android', 2, JSON_OBJECT('suffix', '.apk'), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'TERM_STD', 'TERMINAL', NULL, '通用终端', 'Standard Terminal', 1, JSON_OBJECT('vendor', 'Generic', 'os', 'Android'), 1, 1, NOW(), NOW()),
|
||||
('client_platform', 'TERM_S100','TERMINAL', NULL, '中兴 S100', 'ZTE S100', 2, JSON_OBJECT('vendor', 'ZTE', 'os', 'Android'), 0, 1, NOW(), NOW()),
|
||||
('client_platform', 'TERM_A133','TERMINAL', NULL, '全志 A133', 'Allwinner A133', 3, JSON_OBJECT('vendor', 'Allwinner', 'os', 'Android'), 0, 1, NOW(), NOW()),
|
||||
|
||||
('terminal_type', 'TERM_STD', 'ROOT', NULL, '通用终端', 'Standard Terminal', 1, NULL, 1, 1, NOW(), NOW()),
|
||||
('terminal_type', 'TERM_S100','ROOT', NULL, '中兴 S100', 'ZTE S100', 2, NULL, 0, 1, NOW(), NOW()),
|
||||
('terminal_type', 'TERM_A133','ROOT', NULL, '全志 A133', 'Allwinner A133', 3, NULL, 0, 1, NOW(), NOW()),
|
||||
|
||||
('task_type', 'MEETING_TASK', 'ROOT', NULL, '会议任务', 'Meeting Task', 1, NULL, 0, 1, NOW(), NOW()),
|
||||
('task_type', 'KNOWLEDGE_TASK', 'ROOT', NULL, '知识库任务', 'Knowledge Task', 2, NULL, 0, 1, NOW(), NOW()),
|
||||
|
||||
('external_apps', 'NATIVE', 'ROOT', NULL, '原生应用', 'Native App', 1, JSON_OBJECT('protocol', 'apk'), 0, 1, NOW(), NOW()),
|
||||
('external_apps', 'WEB', 'ROOT', NULL, 'Web应用', 'Web App', 2, JSON_OBJECT('protocol', 'https'), 0, 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`parent_code` = VALUES(`parent_code`),
|
||||
`tree_path` = VALUES(`tree_path`),
|
||||
`label_cn` = VALUES(`label_cn`),
|
||||
`label_en` = VALUES(`label_en`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`extension_attr` = VALUES(`extension_attr`),
|
||||
`is_default` = VALUES(`is_default`),
|
||||
`status` = VALUES(`status`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 8. 菜单定义
|
||||
-- 最终菜单树:
|
||||
-- dashboard
|
||||
-- desktop
|
||||
-- meeting_manage
|
||||
-- ├─ meeting_center
|
||||
-- └─ prompt_config
|
||||
-- platform_admin
|
||||
-- ├─ hot_word_management
|
||||
-- ├─ model_management
|
||||
-- ├─ prompt_management
|
||||
-- ├─ client_management
|
||||
-- ├─ external_app_management
|
||||
-- └─ terminal_management
|
||||
-- system_management
|
||||
-- ├─ user_management
|
||||
-- ├─ permission_management
|
||||
-- │ └─ permission_menu_tree (隐藏)
|
||||
-- ├─ dict_management
|
||||
-- └─ parameter_management
|
||||
-- =====================================================================
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
('dashboard', 'Dashboard', 'DashboardOutlined', '/dashboard', 'link', NULL, 1, NULL, 1, 1, 1, '管理员桌面', NOW(), NOW()),
|
||||
('desktop', 'Desktop', 'DesktopOutlined', '/dashboard', 'link', NULL, 1, NULL, 2, 1, 1, '普通用户桌面', NOW(), NOW()),
|
||||
('meeting_manage', '会议管理', 'CalendarOutlined', '/meetings/center', 'link', NULL, 1, NULL, 3, 1, 1, '普通用户会议功能入口', NOW(), NOW()),
|
||||
('platform_admin', '平台管理', 'Shield', '/admin/management/hot-word-management', 'link', NULL, 1, NULL, 4, 1, 1, '平台能力配置入口', NOW(), NOW()),
|
||||
('system_management', '系统管理', 'Setting', '/admin/management/system-overview', 'link', NULL, 1, NULL, 5, 1, 1, '系统治理入口', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
SET @meeting_manage_id := (SELECT `menu_id` FROM `sys_menus` WHERE `menu_code` = 'meeting_manage' LIMIT 1);
|
||||
SET @platform_admin_id := (SELECT `menu_id` FROM `sys_menus` WHERE `menu_code` = 'platform_admin' LIMIT 1);
|
||||
SET @system_management_id := (SELECT `menu_id` FROM `sys_menus` WHERE `menu_code` = 'system_management' LIMIT 1);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'meeting_center', '会议中心', 'CalendarOutlined', '/meetings/center', 'link',
|
||||
@meeting_manage_id, 2, NULL, 1, 1, 1, '普通用户会议中心', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @meeting_manage_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'prompt_config', '提示词配置', 'Book', '/prompt-config', 'link',
|
||||
@meeting_manage_id, 2, NULL, 2, 1, 1, '用户提示词启用与排序配置', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @meeting_manage_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'hot_word_management', '热词管理', 'Text', '/admin/management/hot-word-management', 'link',
|
||||
@platform_admin_id, 2, NULL, 1, 1, 1, 'ASR 热词管理与同步', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @platform_admin_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'model_management', '模型管理', 'Appstore', '/admin/management/model-management', 'link',
|
||||
@platform_admin_id, 2, NULL, 2, 1, 1, '音频模型与 LLM 模型配置', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @platform_admin_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'prompt_management', '提示词库', 'BookText', '/prompt-management', 'link',
|
||||
@platform_admin_id, 2, NULL, 3, 1, 1, '系统提示词库管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @platform_admin_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'client_management', '客户端管理', 'Smartphone', '/admin/management/client-management', 'link',
|
||||
@platform_admin_id, 2, NULL, 4, 1, 1, '客户端下载与版本管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @platform_admin_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'external_app_management', '外部应用管理', 'AppWindow', '/admin/management/external-app-management', 'link',
|
||||
@platform_admin_id, 2, NULL, 5, 1, 1, '外部系统入口管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @platform_admin_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'terminal_management', '终端管理', 'Monitor', '/admin/management/terminal-management', 'link',
|
||||
@platform_admin_id, 2, NULL, 6, 1, 1, '专用终端管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @platform_admin_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'user_management', '用户管理', 'Users', '/admin/management/user-management', 'link',
|
||||
@system_management_id, 2, NULL, 1, 1, 1, '账号、角色、密码管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @system_management_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'permission_management', '权限管理', 'KeyRound', '/admin/management/permission-management', 'link',
|
||||
@system_management_id, 2, NULL, 2, 1, 1, '菜单与角色授权管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @system_management_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'dict_management', '字典管理', 'BookMarked', '/admin/management/dict-management', 'link',
|
||||
@system_management_id, 2, NULL, 3, 1, 1, '平台字典与码表管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @system_management_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'parameter_management', '参数管理', 'Setting', '/admin/management/parameter-management', 'link',
|
||||
@system_management_id, 2, NULL, 4, 1, 1, '系统参数管理', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @system_management_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
SET @permission_management_id := (
|
||||
SELECT `menu_id`
|
||||
FROM `sys_menus`
|
||||
WHERE `menu_code` = 'permission_management'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
INSERT INTO `sys_menus`
|
||||
(`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `parent_id`, `menu_level`, `tree_path`,
|
||||
`sort_order`, `is_active`, `is_visible`, `description`, `created_at`, `updated_at`)
|
||||
SELECT
|
||||
'permission_menu_tree', '菜单树维护', 'AppstoreAdd', '/admin/management/permission-management', 'link',
|
||||
@permission_management_id, 3, NULL, 20, 1, 0, '权限管理中的隐藏入口,用于菜单树维护。', NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE @permission_management_id IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`menu_name` = VALUES(`menu_name`),
|
||||
`menu_icon` = VALUES(`menu_icon`),
|
||||
`menu_url` = VALUES(`menu_url`),
|
||||
`menu_type` = VALUES(`menu_type`),
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`menu_level` = VALUES(`menu_level`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`is_active` = VALUES(`is_active`),
|
||||
`is_visible` = VALUES(`is_visible`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = VALUES(`updated_at`);
|
||||
|
||||
-- 回填本次初始化菜单的树结构
|
||||
UPDATE `sys_menus`
|
||||
SET
|
||||
`menu_level` = 1,
|
||||
`tree_path` = CONCAT('/', `menu_id`)
|
||||
WHERE `menu_code` IN ('dashboard', 'desktop', 'meeting_manage', 'platform_admin', 'system_management');
|
||||
|
||||
UPDATE `sys_menus` c
|
||||
JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id`
|
||||
SET
|
||||
c.`menu_level` = p.`menu_level` + 1,
|
||||
c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`)
|
||||
WHERE c.`menu_code` IN (
|
||||
'meeting_center',
|
||||
'prompt_config',
|
||||
'hot_word_management',
|
||||
'model_management',
|
||||
'prompt_management',
|
||||
'client_management',
|
||||
'external_app_management',
|
||||
'terminal_management',
|
||||
'user_management',
|
||||
'permission_management',
|
||||
'dict_management',
|
||||
'parameter_management'
|
||||
);
|
||||
|
||||
UPDATE `sys_menus` c
|
||||
JOIN `sys_menus` p ON c.`parent_id` = p.`menu_id`
|
||||
SET
|
||||
c.`menu_level` = p.`menu_level` + 1,
|
||||
c.`tree_path` = CONCAT(p.`tree_path`, '/', c.`menu_id`)
|
||||
WHERE c.`menu_code` IN ('permission_menu_tree');
|
||||
|
||||
-- =====================================================================
|
||||
-- 9. 角色授权
|
||||
-- 管理员: 所有启用菜单
|
||||
-- 普通用户: dashboard / desktop / meeting_manage / meeting_center / prompt_config
|
||||
-- =====================================================================
|
||||
INSERT INTO `sys_role_menu_permissions`
|
||||
(`role_id`, `menu_id`, `granted_by`, `granted_at`)
|
||||
SELECT
|
||||
1,
|
||||
m.`menu_id`,
|
||||
1,
|
||||
NOW()
|
||||
FROM `sys_menus` m
|
||||
WHERE m.`is_active` = 1
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`granted_by` = VALUES(`granted_by`),
|
||||
`granted_at` = VALUES(`granted_at`);
|
||||
|
||||
INSERT INTO `sys_role_menu_permissions`
|
||||
(`role_id`, `menu_id`, `granted_by`, `granted_at`)
|
||||
SELECT
|
||||
2,
|
||||
m.`menu_id`,
|
||||
1,
|
||||
NOW()
|
||||
FROM `sys_menus` m
|
||||
WHERE m.`is_active` = 1
|
||||
AND m.`menu_code` IN ('dashboard', 'desktop', 'meeting_manage', 'meeting_center', 'prompt_config')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`granted_by` = VALUES(`granted_by`),
|
||||
`granted_at` = VALUES(`granted_at`);
|
||||
|
||||
-- =====================================================================
|
||||
-- 10. 自增游标兜底
|
||||
-- =====================================================================
|
||||
COMMIT;
|
||||
|
||||
ALTER TABLE `sys_roles` AUTO_INCREMENT = 3;
|
||||
ALTER TABLE `sys_users` AUTO_INCREMENT = 3;
|
||||
ALTER TABLE `prompts` AUTO_INCREMENT = 3;
|
||||
25548
backend/sql/imeeting.sql
25548
backend/sql/imeeting.sql
File diff suppressed because one or more lines are too long
|
|
@ -1,67 +0,0 @@
|
|||
-- 提示词表改造迁移脚本
|
||||
-- 将 prompt_config 表的功能整合到 prompts 表
|
||||
|
||||
-- 步骤1: 添加新字段
|
||||
ALTER TABLE prompts
|
||||
ADD COLUMN task_type ENUM('MEETING_TASK', 'KNOWLEDGE_TASK')
|
||||
COMMENT '任务类型:MEETING_TASK-会议任务, KNOWLEDGE_TASK-知识库任务' AFTER name;
|
||||
|
||||
ALTER TABLE prompts
|
||||
ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE
|
||||
COMMENT '是否为该任务类型的默认模板' AFTER content;
|
||||
|
||||
-- 步骤2: 修改 is_active 字段(如果存在且类型不是 BOOLEAN)
|
||||
-- 先检查字段是否存在,如果不存在则添加
|
||||
ALTER TABLE prompts
|
||||
MODIFY COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
COMMENT '是否启用(只有启用的提示词才能被使用)';
|
||||
|
||||
-- 步骤3: 删除 tags 字段
|
||||
ALTER TABLE prompts DROP COLUMN IF EXISTS tags;
|
||||
|
||||
-- 步骤4: 从 prompt_config 迁移数据(如果 prompt_config 表存在)
|
||||
-- 更新 task_type 和 is_default
|
||||
UPDATE prompts p
|
||||
LEFT JOIN prompt_config pc ON p.id = pc.prompt_id
|
||||
SET
|
||||
p.task_type = CASE
|
||||
WHEN pc.task_name IS NOT NULL THEN pc.task_name
|
||||
ELSE 'MEETING_TASK' -- 默认值
|
||||
END,
|
||||
p.is_default = CASE
|
||||
WHEN pc.is_default = 1 THEN TRUE
|
||||
ELSE FALSE
|
||||
END
|
||||
WHERE pc.prompt_id IS NOT NULL OR p.task_type IS NULL;
|
||||
|
||||
-- 步骤5: 为所有没有设置 task_type 的提示词设置默认值
|
||||
UPDATE prompts
|
||||
SET task_type = 'MEETING_TASK'
|
||||
WHERE task_type IS NULL;
|
||||
|
||||
-- 步骤6: 将 task_type 设置为 NOT NULL
|
||||
ALTER TABLE prompts
|
||||
MODIFY COLUMN task_type ENUM('MEETING_TASK', 'KNOWLEDGE_TASK') NOT NULL
|
||||
COMMENT '任务类型:MEETING_TASK-会议任务, KNOWLEDGE_TASK-知识库任务';
|
||||
|
||||
-- 步骤7: 确保每个 task_type 只有一个默认提示词
|
||||
-- 如果有多个默认,只保留 id 最小的那个
|
||||
UPDATE prompts p1
|
||||
LEFT JOIN (
|
||||
SELECT task_type, MIN(id) as min_id
|
||||
FROM prompts
|
||||
WHERE is_default = TRUE
|
||||
GROUP BY task_type
|
||||
) p2 ON p1.task_type = p2.task_type
|
||||
SET p1.is_default = FALSE
|
||||
WHERE p1.is_default = TRUE AND p1.id != p2.min_id;
|
||||
|
||||
-- 步骤8: (可选) 备注 prompt_config 表已废弃
|
||||
-- 如果需要删除 prompt_config 表,取消下面的注释
|
||||
-- DROP TABLE IF EXISTS prompt_config;
|
||||
|
||||
-- 迁移完成
|
||||
SELECT '提示词表迁移完成!' as message;
|
||||
SELECT task_type, COUNT(*) as total, SUM(is_default) as default_count
|
||||
FROM prompts
|
||||
GROUP BY task_type;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
-- 为terminals表添加当前绑定用户ID字段
|
||||
ALTER TABLE `terminals`
|
||||
ADD COLUMN `current_user_id` INT DEFAULT NULL COMMENT '当前绑定/使用的用户ID',
|
||||
ADD CONSTRAINT `fk_terminals_current_user` FOREIGN KEY (`current_user_id`) REFERENCES `users` (`user_id`) ON DELETE SET NULL;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
-- ============================================
|
||||
-- 为 audio_files 表添加 duration 字段
|
||||
-- 创建时间: 2025-01-26
|
||||
-- 说明: 添加音频时长字段(秒),用于统计用户会议总时长
|
||||
-- ============================================
|
||||
|
||||
-- 添加 duration 字段(单位:秒)
|
||||
ALTER TABLE audio_files
|
||||
ADD COLUMN duration INT(11) DEFAULT 0 COMMENT '音频时长(秒)'
|
||||
AFTER file_size;
|
||||
|
||||
-- 添加索引以提高查询性能
|
||||
ALTER TABLE audio_files
|
||||
ADD INDEX idx_duration (duration);
|
||||
|
||||
-- ============================================
|
||||
-- 验证修改
|
||||
-- ============================================
|
||||
-- 查看表结构
|
||||
-- DESCRIBE audio_files;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
-- ============================================
|
||||
-- 添加 prompt_id 字段到主表
|
||||
-- 创建时间: 2025-01-11
|
||||
-- 说明: 在 meetings 和 knowledge_bases 表中添加 prompt_id 字段
|
||||
-- 用于记录会议/知识库使用的提示词模版
|
||||
-- ============================================
|
||||
|
||||
-- 1. 为 meetings 表添加 prompt_id 字段
|
||||
ALTER TABLE meetings
|
||||
ADD COLUMN prompt_id INT(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版'
|
||||
AFTER summary;
|
||||
|
||||
-- 为 meetings 表添加索引
|
||||
ALTER TABLE meetings
|
||||
ADD INDEX idx_prompt_id (prompt_id);
|
||||
|
||||
-- 2. 为 knowledge_bases 表添加 prompt_id 字段
|
||||
ALTER TABLE knowledge_bases
|
||||
ADD COLUMN prompt_id INT(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版'
|
||||
AFTER tags;
|
||||
|
||||
-- 为 knowledge_bases 表添加索引
|
||||
ALTER TABLE knowledge_bases
|
||||
ADD INDEX idx_prompt_id (prompt_id);
|
||||
|
||||
-- ============================================
|
||||
-- 验证修改
|
||||
-- ============================================
|
||||
-- 查看 meetings 表结构
|
||||
-- DESCRIBE meetings;
|
||||
|
||||
-- 查看 knowledge_bases 表结构
|
||||
-- DESCRIBE knowledge_bases;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
-- ============================================
|
||||
-- 创建用户日志表 (user_logs)
|
||||
-- 创建时间: 2025-01-26
|
||||
-- 说明: 用于记录用户活动日志,包括登录、登出等操作
|
||||
-- 支持查询用户最后登录时间等统计信息
|
||||
-- ============================================
|
||||
|
||||
-- 创建 user_logs 表
|
||||
CREATE TABLE IF NOT EXISTS user_logs (
|
||||
log_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID',
|
||||
user_id INT(11) NOT NULL COMMENT '用户ID',
|
||||
action_type VARCHAR(50) NOT NULL COMMENT '操作类型: login, logout, etc.',
|
||||
ip_address VARCHAR(50) DEFAULT NULL COMMENT '用户IP地址',
|
||||
user_agent TEXT DEFAULT NULL COMMENT '用户代理字符串(浏览器/设备信息)',
|
||||
metadata JSON DEFAULT NULL COMMENT '额外的元数据(JSON格式)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '日志创建时间',
|
||||
|
||||
-- 索引
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_action_type (action_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_user_action (user_id, action_type),
|
||||
|
||||
-- 外键约束
|
||||
CONSTRAINT fk_user_logs_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户活动日志表';
|
||||
|
||||
-- ============================================
|
||||
-- 验证创建
|
||||
-- ============================================
|
||||
-- 查看表结构
|
||||
-- DESCRIBE user_logs;
|
||||
|
||||
-- 查看索引
|
||||
-- SHOW INDEX FROM user_logs;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
-- Migration: Add avatar_url and update menu for Account Settings
|
||||
-- Created at: 2026-01-15
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Add avatar_url to users table if it doesn't exist
|
||||
-- Note: MySQL 5.7 doesn't support IF NOT EXISTS for columns easily in one line without procedure,
|
||||
-- but for this environment we assume it doesn't exist or ignore error if strictly handled.
|
||||
-- However, creating a safe idempotent script is better.
|
||||
-- Since I can't run complex procedures easily here, I'll just run the ALTER.
|
||||
-- If it fails, it fails (user can ignore if already applied).
|
||||
ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(512) DEFAULT NULL AFTER `email`;
|
||||
|
||||
-- 2. Remove 'change_password' menu
|
||||
DELETE FROM `role_menu_permissions` WHERE `menu_id` IN (SELECT `menu_id` FROM `menus` WHERE `menu_code` = 'change_password');
|
||||
DELETE FROM `menus` WHERE `menu_code` = 'change_password';
|
||||
|
||||
-- 3. Add 'account_settings' menu
|
||||
INSERT INTO `menus` (`menu_code`, `menu_name`, `menu_icon`, `menu_url`, `menu_type`, `sort_order`, `is_active`, `description`)
|
||||
VALUES ('account_settings', '账户设置', 'UserCog', '/account-settings', 'link', 1, 1, '管理个人账户信息');
|
||||
|
||||
-- 4. Grant permissions
|
||||
-- Grant to Admin (role_id=1) and User (role_id=2)
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 1, menu_id FROM `menus` WHERE `menu_code` = 'account_settings';
|
||||
|
||||
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`)
|
||||
SELECT 2, menu_id FROM `menus` WHERE `menu_code` = 'account_settings';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
-- Migration to unify LLM config into a single dict entry 'llm_model'
|
||||
-- Using dict_type='system_config' and dict_code='llm_model'
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Insert default LLM configuration
|
||||
INSERT INTO `dict_data` (
|
||||
`dict_type`, `dict_code`, `parent_code`, `label_cn`, `label_en`,
|
||||
`sort_order`, `extension_attr`, `is_default`, `status`
|
||||
) VALUES (
|
||||
'system_config', 'llm_model', 'ROOT', '大模型配置', 'LLM Model Config',
|
||||
0, '{"model_name": "qwen-plus", "timeout": 120, "temperature": 0.7, "top_p": 0.9}', 0, 1
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`label_cn` = VALUES(`label_cn`);
|
||||
-- Note: We avoid overwriting extension_attr on duplicate key to preserve existing settings if any,
|
||||
-- UNLESS we want to force reset. The user said "refer to...", implying structure exists or should be this.
|
||||
-- If I want to ensure the structure exists with keys, I might need to merge.
|
||||
-- For simplicity, if it exists, I assume it's correct or managed by admin UI.
|
||||
-- But since this is a new "unification", likely it doesn't exist or we want to establish defaults.
|
||||
-- Let's update extension_attr if it's NULL, or just leave it.
|
||||
-- Actually, if I am changing the SCHEMA of config (from individual to unified),
|
||||
-- I should probably populate it.
|
||||
-- Since I cannot easily read old values here, I will just ensure the entry exists.
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
-- 更新voiceprint配置
|
||||
-- 将dict_type='system_config'且dict_code='voiceprint_template'的记录改为 dict_type='voiceprint' 且 dict_code='voiceprint'
|
||||
-- 或者如果已经存在voiceprint类型的配置,则更新它
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. 尝试删除旧的voiceprint配置(如果存在)
|
||||
DELETE FROM `dict_data` WHERE `dict_type` = 'system_config' AND `dict_code` = 'voiceprint_template';
|
||||
|
||||
-- 2. 插入或更新新的voiceprint配置
|
||||
INSERT INTO `dict_data` (
|
||||
`dict_type`, `dict_code`, `parent_code`, `label_cn`, `label_en`,
|
||||
`sort_order`, `extension_attr`, `is_default`, `status`
|
||||
) VALUES (
|
||||
'voiceprint', 'voiceprint', 'ROOT', '声纹配置', 'Voiceprint Config',
|
||||
0, '{"channels": 1, "sample_rate": 16000, "template_text": "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。", "duration_seconds": 12}', 0, 1
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`extension_attr` = VALUES(`extension_attr`),
|
||||
`label_cn` = VALUES(`label_cn`);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
-- 为现有数据库添加转录任务支持的SQL脚本
|
||||
|
||||
-- 1. 更新audio_files表结构,添加缺失字段
|
||||
ALTER TABLE audio_files
|
||||
ADD COLUMN file_name VARCHAR(255) AFTER meeting_id,
|
||||
ADD COLUMN file_size BIGINT DEFAULT NULL AFTER file_path,
|
||||
ADD COLUMN task_id VARCHAR(255) DEFAULT NULL AFTER upload_time;
|
||||
|
||||
-- 2. 创建转录任务表
|
||||
CREATE TABLE transcript_tasks (
|
||||
task_id VARCHAR(255) PRIMARY KEY,
|
||||
meeting_id INT NOT NULL,
|
||||
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
|
||||
progress INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
error_message TEXT NULL,
|
||||
|
||||
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 3. 添加索引以优化查询性能
|
||||
-- audio_files 表索引
|
||||
ALTER TABLE audio_files ADD INDEX idx_task_id (task_id);
|
||||
|
||||
-- transcript_tasks 表索引
|
||||
ALTER TABLE transcript_tasks ADD INDEX idx_meeting_id (meeting_id);
|
||||
ALTER TABLE transcript_tasks ADD INDEX idx_status (status);
|
||||
ALTER TABLE transcript_tasks ADD INDEX idx_created_at (created_at);
|
||||
|
||||
-- 4. 更新现有测试数据(如果需要)
|
||||
-- 这些语句是可选的,用于更新现有的测试数据
|
||||
UPDATE audio_files SET file_name = 'test_audio.mp3' WHERE file_name IS NULL;
|
||||
UPDATE audio_files SET file_size = 10485760 WHERE file_size IS NULL; -- 10MB
|
||||
|
||||
SELECT '转录任务表创建完成!' as message;
|
||||
|
||||
|
||||
CREATE TABLE llm_tasks (
|
||||
task_id VARCHAR(100) PRIMARY KEY,
|
||||
llm_task_id VARCHAR(100) DEFAULT NULL,
|
||||
meeting_id INT NOT NULL,
|
||||
user_prompt TEXT,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
progress INT DEFAULT 0,
|
||||
result TEXT,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
INDEX idx_meeting_id (meeting_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
)
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,162 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
API安全性测试脚本
|
||||
测试添加JWT验证后,API端点是否正确拒绝未授权访问
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate
|
||||
python test/test_api_security.py
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
PROXIES = {'http': None, 'https': None}
|
||||
|
||||
def test_unauthorized_access():
|
||||
"""测试未授权访问各个API端点"""
|
||||
print("=== API安全性测试 ===")
|
||||
print("测试未授权访问是否被正确拒绝\n")
|
||||
|
||||
# 需要验证的API端点
|
||||
protected_endpoints = [
|
||||
# Users endpoints
|
||||
("GET", "/api/users", "获取所有用户"),
|
||||
("GET", "/api/users/1", "获取用户详情"),
|
||||
|
||||
# Meetings endpoints
|
||||
("GET", "/api/meetings", "获取会议列表"),
|
||||
("GET", "/api/meetings/1", "获取会议详情"),
|
||||
("GET", "/api/meetings/1/transcript", "获取会议转录"),
|
||||
("GET", "/api/meetings/1/edit", "获取会议编辑信息"),
|
||||
("GET", "/api/meetings/1/audio", "获取会议音频"),
|
||||
("POST", "/api/meetings/1/regenerate-summary", "重新生成摘要"),
|
||||
("GET", "/api/meetings/1/summaries", "获取会议摘要"),
|
||||
("GET", "/api/meetings/1/transcription/status", "获取转录状态"),
|
||||
|
||||
# Auth endpoints (需要token的)
|
||||
("GET", "/api/auth/me", "获取用户信息"),
|
||||
("POST", "/api/auth/logout", "登出"),
|
||||
("POST", "/api/auth/logout-all", "登出所有设备"),
|
||||
]
|
||||
|
||||
success_count = 0
|
||||
total_count = len(protected_endpoints)
|
||||
|
||||
for method, endpoint, description in protected_endpoints:
|
||||
try:
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
|
||||
if method == "GET":
|
||||
response = requests.get(url, proxies=PROXIES, timeout=5)
|
||||
elif method == "POST":
|
||||
response = requests.post(url, proxies=PROXIES, timeout=5)
|
||||
elif method == "PUT":
|
||||
response = requests.put(url, proxies=PROXIES, timeout=5)
|
||||
elif method == "DELETE":
|
||||
response = requests.delete(url, proxies=PROXIES, timeout=5)
|
||||
|
||||
if response.status_code == 401:
|
||||
print(f"✅ {method} {endpoint} - {description}")
|
||||
print(f" 正确返回401 Unauthorized")
|
||||
success_count += 1
|
||||
else:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 错误:返回 {response.status_code},应该返回401")
|
||||
print(f" 响应: {response.text[:100]}...")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 请求异常: {e}")
|
||||
|
||||
print()
|
||||
|
||||
print(f"=== 测试结果 ===")
|
||||
print(f"通过: {success_count}/{total_count}")
|
||||
print(f"成功率: {success_count/total_count*100:.1f}%")
|
||||
|
||||
if success_count == total_count:
|
||||
print("🎉 所有API端点都正确实施了JWT验证!")
|
||||
else:
|
||||
print("⚠️ 有些API端点未正确实施JWT验证,需要修复")
|
||||
|
||||
return success_count == total_count
|
||||
|
||||
def test_valid_token_access():
|
||||
"""测试有效token的访问"""
|
||||
print("\n=== 测试有效Token访问 ===")
|
||||
|
||||
# 1. 先登录获取token
|
||||
login_data = {"username": "mula", "password": "781126"}
|
||||
try:
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print("❌ 无法登录获取测试token")
|
||||
print(f"登录响应: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
user_data = response.json()
|
||||
token = user_data["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
print(f"✅ 登录成功,获得token")
|
||||
|
||||
# 2. 测试几个主要API端点
|
||||
test_endpoints = [
|
||||
("GET", "/api/auth/me", "获取当前用户信息"),
|
||||
("GET", "/api/users", "获取用户列表"),
|
||||
("GET", "/api/meetings", "获取会议列表"),
|
||||
]
|
||||
|
||||
success_count = 0
|
||||
for method, endpoint, description in test_endpoints:
|
||||
try:
|
||||
url = f"{BASE_URL}{endpoint}"
|
||||
response = requests.get(url, headers=headers, proxies=PROXIES, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
print(f"✅ {method} {endpoint} - {description}")
|
||||
print(f" 正确返回200 OK")
|
||||
success_count += 1
|
||||
elif response.status_code == 500:
|
||||
print(f"⚠️ {method} {endpoint} - {description}")
|
||||
print(f" 返回500 (可能是数据库连接问题,但JWT验证通过了)")
|
||||
success_count += 1
|
||||
else:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 意外响应: {response.status_code}")
|
||||
print(f" 响应内容: {response.text[:100]}...")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ {method} {endpoint} - {description}")
|
||||
print(f" 请求异常: {e}")
|
||||
|
||||
print(f"\n有效token测试: {success_count}/{len(test_endpoints)} 通过")
|
||||
return success_count == len(test_endpoints)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("API JWT安全性测试工具")
|
||||
print("=" * 50)
|
||||
|
||||
# 测试未授权访问
|
||||
unauthorized_ok = test_unauthorized_access()
|
||||
|
||||
# 测试授权访问
|
||||
authorized_ok = test_valid_token_access()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if unauthorized_ok and authorized_ok:
|
||||
print("🎉 JWT验证实施成功!")
|
||||
print("✅ 未授权访问被正确拒绝")
|
||||
print("✅ 有效token可以正常访问")
|
||||
else:
|
||||
print("⚠️ JWT验证实施不完整")
|
||||
if not unauthorized_ok:
|
||||
print("❌ 部分API未正确拒绝未授权访问")
|
||||
if not authorized_ok:
|
||||
print("❌ 有效token访问存在问题")
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建一个新的测试任务
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.services.async_meeting_service import AsyncMeetingService
|
||||
|
||||
# 创建服务实例
|
||||
service = AsyncMeetingService()
|
||||
|
||||
# 创建测试任务
|
||||
meeting_id = 38
|
||||
user_prompt = "请重点关注决策事项和待办任务"
|
||||
|
||||
print("创建新任务...")
|
||||
task_id = service.start_summary_generation(meeting_id, user_prompt)
|
||||
print(f"✅ 任务创建成功: {task_id}")
|
||||
|
||||
# 获取任务状态
|
||||
status = service.get_task_status(task_id)
|
||||
print(f"任务状态: {status}")
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>JWT Token 测试工具</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
textarea { width: 100%; height: 100px; margin: 10px 0; }
|
||||
.result { background: #f5f5f5; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
.error { background: #ffebee; color: #c62828; }
|
||||
.success { background: #e8f5e8; color: #2e7d2e; }
|
||||
button { padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
button:hover { background: #0056b3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>JWT Token 验证工具</h1>
|
||||
|
||||
<h3>步骤1: 从浏览器获取Token</h3>
|
||||
<p>1. 登录你的应用</p>
|
||||
<p>2. 打开开发者工具 → Application → Local Storage → 找到 'iMeetingUser'</p>
|
||||
<p>3. 复制其中的 token 值到下面的文本框</p>
|
||||
|
||||
<textarea id="tokenInput" placeholder="在此粘贴JWT token..."></textarea>
|
||||
<button onclick="decodeToken()">解码 JWT Token</button>
|
||||
|
||||
<div id="result"></div>
|
||||
|
||||
<h3>预期的JWT payload应该包含:</h3>
|
||||
<ul>
|
||||
<li><code>user_id</code>: 用户ID</li>
|
||||
<li><code>username</code>: 用户名</li>
|
||||
<li><code>caption</code>: 用户显示名</li>
|
||||
<li><code>exp</code>: 过期时间戳</li>
|
||||
<li><code>type</code>: "access"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function decodeToken() {
|
||||
const token = document.getElementById('tokenInput').value.trim();
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
||||
if (!token) {
|
||||
resultDiv.innerHTML = '<div class="result error">请输入JWT token</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// JWT 由三部分组成,用 . 分隔
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('无效的JWT格式');
|
||||
}
|
||||
|
||||
// 解码 header
|
||||
const header = JSON.parse(atob(parts[0]));
|
||||
|
||||
// 解码 payload
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
// 检查是否是我们的JWT
|
||||
const isValidJWT = payload.type === 'access' &&
|
||||
payload.user_id &&
|
||||
payload.username &&
|
||||
payload.exp;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isExpired = payload.exp < now;
|
||||
|
||||
let resultHTML = '<div class="result ' + (isValidJWT ? 'success' : 'error') + '">';
|
||||
resultHTML += '<h4>JWT 解码结果:</h4>';
|
||||
resultHTML += '<p><strong>Header:</strong></p>';
|
||||
resultHTML += '<pre>' + JSON.stringify(header, null, 2) + '</pre>';
|
||||
resultHTML += '<p><strong>Payload:</strong></p>';
|
||||
resultHTML += '<pre>' + JSON.stringify(payload, null, 2) + '</pre>';
|
||||
|
||||
if (isExpired) {
|
||||
resultHTML += '<p style="color: red;"><strong>⚠️ Token已过期!</strong></p>';
|
||||
} else {
|
||||
const expireDate = new Date(payload.exp * 1000);
|
||||
resultHTML += '<p style="color: green;"><strong>✅ Token有效,过期时间: ' + expireDate.toLocaleString() + '</strong></p>';
|
||||
}
|
||||
|
||||
if (isValidJWT) {
|
||||
resultHTML += '<p style="color: green;"><strong>✅ 这是有效的iMeeting JWT token!</strong></p>';
|
||||
} else {
|
||||
resultHTML += '<p style="color: red;"><strong>❌ 这不是有效的iMeeting JWT token</strong></p>';
|
||||
}
|
||||
|
||||
resultHTML += '</div>';
|
||||
resultDiv.innerHTML = resultHTML;
|
||||
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = '<div class="result error">解码失败: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
"""
|
||||
测试知识库提示词模版选择功能
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, 'app')
|
||||
|
||||
from app.services.llm_service import LLMService
|
||||
from app.services.async_knowledge_base_service import AsyncKnowledgeBaseService
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
def test_get_active_knowledge_prompts():
|
||||
"""测试获取启用的知识库提示词列表"""
|
||||
print("\n=== 测试1: 获取启用的知识库提示词列表 ===")
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 获取KNOWLEDGE_TASK类型的启用模版
|
||||
query = """
|
||||
SELECT id, name, is_default
|
||||
FROM prompts
|
||||
WHERE task_type = %s AND is_active = TRUE
|
||||
ORDER BY is_default DESC, created_at DESC
|
||||
"""
|
||||
cursor.execute(query, ('KNOWLEDGE_TASK',))
|
||||
prompts = cursor.fetchall()
|
||||
|
||||
print(f"✓ 找到 {len(prompts)} 个启用的知识库任务模版:")
|
||||
for p in prompts:
|
||||
default_flag = " [默认]" if p['is_default'] else ""
|
||||
print(f" - ID: {p['id']}, 名称: {p['name']}{default_flag}")
|
||||
|
||||
return prompts
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def test_get_task_prompt_with_id(prompts):
|
||||
"""测试通过prompt_id获取知识库提示词内容"""
|
||||
print("\n=== 测试2: 通过prompt_id获取知识库提示词内容 ===")
|
||||
|
||||
if not prompts:
|
||||
print("⚠ 没有可用的提示词模版,跳过测试")
|
||||
return
|
||||
|
||||
llm_service = LLMService()
|
||||
|
||||
# 测试获取第一个提示词
|
||||
test_prompt = prompts[0]
|
||||
try:
|
||||
content = llm_service.get_task_prompt('KNOWLEDGE_TASK', prompt_id=test_prompt['id'])
|
||||
print(f"✓ 成功获取提示词 ID={test_prompt['id']}, 名称={test_prompt['name']}")
|
||||
print(f" 内容长度: {len(content)} 字符")
|
||||
print(f" 内容预览: {content[:100]}...")
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 测试获取默认提示词(不指定prompt_id)
|
||||
try:
|
||||
default_content = llm_service.get_task_prompt('KNOWLEDGE_TASK')
|
||||
print(f"✓ 成功获取默认提示词")
|
||||
print(f" 内容长度: {len(default_content)} 字符")
|
||||
except Exception as e:
|
||||
print(f"✗ 获取默认提示词失败: {e}")
|
||||
|
||||
def test_async_kb_service_signature():
|
||||
"""测试async_knowledge_base_service的方法签名"""
|
||||
print("\n=== 测试3: 验证方法签名支持prompt_id参数 ===")
|
||||
|
||||
import inspect
|
||||
async_service = AsyncKnowledgeBaseService()
|
||||
|
||||
# 检查start_generation方法签名
|
||||
sig = inspect.signature(async_service.start_generation)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params:
|
||||
print(f"✓ start_generation 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
else:
|
||||
print(f"✗ start_generation 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
|
||||
# 检查_build_prompt方法签名
|
||||
sig2 = inspect.signature(async_service._build_prompt)
|
||||
params2 = list(sig2.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params2:
|
||||
print(f"✓ _build_prompt 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
else:
|
||||
print(f"✗ _build_prompt 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
|
||||
def test_database_schema():
|
||||
"""测试数据库schema是否包含prompt_id列"""
|
||||
print("\n=== 测试4: 验证数据库schema ===")
|
||||
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查knowledge_base_tasks表是否有prompt_id列
|
||||
cursor.execute("""
|
||||
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'knowledge_base_tasks'
|
||||
AND COLUMN_NAME = 'prompt_id'
|
||||
""")
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
print(f"✓ knowledge_base_tasks 表包含 prompt_id 列")
|
||||
print(f" 类型: {result['DATA_TYPE']}")
|
||||
print(f" 可空: {result['IS_NULLABLE']}")
|
||||
print(f" 默认值: {result['COLUMN_DEFAULT']}")
|
||||
else:
|
||||
print(f"✗ knowledge_base_tasks 表缺少 prompt_id 列")
|
||||
except Exception as e:
|
||||
print(f"✗ 数据库检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def test_api_model():
|
||||
"""测试API模型定义"""
|
||||
print("\n=== 测试5: 验证API模型定义 ===")
|
||||
|
||||
try:
|
||||
from app.models.models import CreateKnowledgeBaseRequest
|
||||
import inspect
|
||||
|
||||
# 检查CreateKnowledgeBaseRequest模型
|
||||
fields = CreateKnowledgeBaseRequest.model_fields
|
||||
|
||||
if 'prompt_id' in fields:
|
||||
print(f"✓ CreateKnowledgeBaseRequest 包含 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
else:
|
||||
print(f"✗ CreateKnowledgeBaseRequest 缺少 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ API模型检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("开始测试知识库提示词模版选择功能")
|
||||
print("=" * 60)
|
||||
|
||||
# 运行所有测试
|
||||
prompts = test_get_active_knowledge_prompts()
|
||||
test_get_task_prompt_with_id(prompts)
|
||||
test_async_kb_service_signature()
|
||||
test_database_schema()
|
||||
test_api_model()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
print("=" * 60)
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
登录调试脚本 - 诊断JWT认证问题
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate # 激活虚拟环境
|
||||
python test/test_login_debug.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
# 禁用代理以避免本地请求被代理
|
||||
PROXIES = {'http': None, 'https': None}
|
||||
|
||||
def test_backend_connection():
|
||||
"""测试后端连接"""
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/", proxies=PROXIES)
|
||||
print(f"✅ 后端服务连接成功: {response.status_code}")
|
||||
return True
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 无法连接到后端服务")
|
||||
return False
|
||||
|
||||
def test_login_with_debug(username, password):
|
||||
"""详细的登录测试"""
|
||||
print(f"\n=== 测试登录: {username} ===")
|
||||
|
||||
login_data = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"请求URL: {BASE_URL}/api/auth/login")
|
||||
print(f"请求数据: {json.dumps(login_data, ensure_ascii=False)}")
|
||||
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=login_data, proxies=PROXIES)
|
||||
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
print(f"响应头: {dict(response.headers)}")
|
||||
|
||||
if response.status_code == 200:
|
||||
user_data = response.json()
|
||||
print("✅ 登录成功!")
|
||||
print(f"用户信息: {json.dumps(user_data, ensure_ascii=False, indent=2)}")
|
||||
return user_data.get("token")
|
||||
else:
|
||||
print("❌ 登录失败")
|
||||
print(f"错误内容: {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 请求异常: {e}")
|
||||
return None
|
||||
|
||||
def test_authenticated_request(token):
|
||||
"""测试认证请求"""
|
||||
if not token:
|
||||
print("❌ 没有有效token,跳过认证测试")
|
||||
return
|
||||
|
||||
print(f"\n=== 测试认证请求 ===")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
# 测试 /api/auth/me
|
||||
print("测试 /api/auth/me")
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=headers, proxies=PROXIES)
|
||||
print(f"状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print("✅ 认证请求成功")
|
||||
print(f"用户信息: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
else:
|
||||
print("❌ 认证请求失败")
|
||||
print(f"错误: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 认证请求异常: {e}")
|
||||
|
||||
def check_database_users():
|
||||
"""检查数据库用户"""
|
||||
try:
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
print(f"\n=== 检查数据库用户 ===")
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT user_id, username, caption, email FROM users LIMIT 10")
|
||||
users = cursor.fetchall()
|
||||
|
||||
print(f"数据库中的用户 (前10个):")
|
||||
for user in users:
|
||||
print(f" - ID: {user['user_id']}, 用户名: {user['username']}, 名称: {user['caption']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 无法访问数据库: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("JWT登录调试工具")
|
||||
print("=" * 50)
|
||||
|
||||
# 1. 测试后端连接
|
||||
if not test_backend_connection():
|
||||
exit(1)
|
||||
|
||||
# 2. 检查数据库用户
|
||||
check_database_users()
|
||||
|
||||
# 3. 测试登录
|
||||
username = input("\n请输入用户名 (默认: mula): ").strip() or "mula"
|
||||
password = input("请输入密码 (默认: 781126): ").strip() or "781126"
|
||||
|
||||
token = test_login_with_debug(username, password)
|
||||
|
||||
# 4. 测试认证请求
|
||||
test_authenticated_request(token)
|
||||
|
||||
print("\n=== 调试完成 ===")
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试菜单权限数据是否存在
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
def test_menu_permissions():
|
||||
print("=== 测试菜单权限数据 ===\n")
|
||||
|
||||
try:
|
||||
# 连接数据库
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 1. 检查menus表
|
||||
print("1. 检查menus表:")
|
||||
cursor.execute("SELECT COUNT(*) as count FROM menus")
|
||||
menu_count = cursor.fetchone()['count']
|
||||
print(f" - 菜单总数: {menu_count}")
|
||||
|
||||
if menu_count > 0:
|
||||
cursor.execute("SELECT menu_id, menu_code, menu_name, is_active FROM menus ORDER BY sort_order")
|
||||
menus = cursor.fetchall()
|
||||
for menu in menus:
|
||||
print(f" - [{menu['menu_id']}] {menu['menu_name']} ({menu['menu_code']}) - 启用: {menu['is_active']}")
|
||||
else:
|
||||
print(" ⚠️ menus表为空!")
|
||||
|
||||
print()
|
||||
|
||||
# 2. 检查roles表
|
||||
print("2. 检查roles表:")
|
||||
cursor.execute("SELECT * FROM roles ORDER BY role_id")
|
||||
roles = cursor.fetchall()
|
||||
for role in roles:
|
||||
print(f" - [{role['role_id']}] {role['role_name']}")
|
||||
|
||||
print()
|
||||
|
||||
# 3. 检查role_menu_permissions表
|
||||
print("3. 检查role_menu_permissions表:")
|
||||
cursor.execute("SELECT COUNT(*) as count FROM role_menu_permissions")
|
||||
perm_count = cursor.fetchone()['count']
|
||||
print(f" - 权限总数: {perm_count}")
|
||||
|
||||
if perm_count > 0:
|
||||
cursor.execute("""
|
||||
SELECT r.role_name, m.menu_name, rmp.role_id, rmp.menu_id
|
||||
FROM role_menu_permissions rmp
|
||||
JOIN roles r ON rmp.role_id = r.role_id
|
||||
JOIN menus m ON rmp.menu_id = m.menu_id
|
||||
ORDER BY rmp.role_id, m.sort_order
|
||||
""")
|
||||
permissions = cursor.fetchall()
|
||||
|
||||
current_role = None
|
||||
for perm in permissions:
|
||||
if current_role != perm['role_name']:
|
||||
current_role = perm['role_name']
|
||||
print(f"\n {current_role}的权限:")
|
||||
print(f" - {perm['menu_name']}")
|
||||
else:
|
||||
print(" ⚠️ role_menu_permissions表为空!")
|
||||
|
||||
print("\n" + "="*50)
|
||||
|
||||
# 4. 检查是否需要执行SQL脚本
|
||||
if menu_count == 0 or perm_count == 0:
|
||||
print("\n❌ 数据库中缺少菜单或权限数据!")
|
||||
print("请执行以下命令初始化数据:")
|
||||
print("\nmysql -h 10.100.51.161 -u root -psagacity imeeting_dev < backend/sql/add_menu_permissions_system.sql")
|
||||
print("\n或者在MySQL客户端中执行该SQL文件。")
|
||||
else:
|
||||
print("\n✅ 菜单权限数据正常!")
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_menu_permissions()
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
"""
|
||||
测试提示词模版选择功能
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, 'app')
|
||||
|
||||
from app.services.llm_service import LLMService
|
||||
from app.services.async_meeting_service import AsyncMeetingService
|
||||
from app.core.database import get_db_connection
|
||||
|
||||
def test_get_active_prompts():
|
||||
"""测试获取启用的提示词列表"""
|
||||
print("\n=== 测试1: 获取启用的提示词列表 ===")
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 获取MEETING_TASK类型的启用模版
|
||||
query = """
|
||||
SELECT id, name, is_default
|
||||
FROM prompts
|
||||
WHERE task_type = %s AND is_active = TRUE
|
||||
ORDER BY is_default DESC, created_at DESC
|
||||
"""
|
||||
cursor.execute(query, ('MEETING_TASK',))
|
||||
prompts = cursor.fetchall()
|
||||
|
||||
print(f"✓ 找到 {len(prompts)} 个启用的会议任务模版:")
|
||||
for p in prompts:
|
||||
default_flag = " [默认]" if p['is_default'] else ""
|
||||
print(f" - ID: {p['id']}, 名称: {p['name']}{default_flag}")
|
||||
|
||||
return prompts
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def test_get_task_prompt_with_id(prompts):
|
||||
"""测试通过prompt_id获取提示词内容"""
|
||||
print("\n=== 测试2: 通过prompt_id获取提示词内容 ===")
|
||||
|
||||
if not prompts:
|
||||
print("⚠ 没有可用的提示词模版,跳过测试")
|
||||
return
|
||||
|
||||
llm_service = LLMService()
|
||||
|
||||
# 测试获取第一个提示词
|
||||
test_prompt = prompts[0]
|
||||
try:
|
||||
content = llm_service.get_task_prompt('MEETING_TASK', prompt_id=test_prompt['id'])
|
||||
print(f"✓ 成功获取提示词 ID={test_prompt['id']}, 名称={test_prompt['name']}")
|
||||
print(f" 内容长度: {len(content)} 字符")
|
||||
print(f" 内容预览: {content[:100]}...")
|
||||
except Exception as e:
|
||||
print(f"✗ 测试失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 测试获取默认提示词(不指定prompt_id)
|
||||
try:
|
||||
default_content = llm_service.get_task_prompt('MEETING_TASK')
|
||||
print(f"✓ 成功获取默认提示词")
|
||||
print(f" 内容长度: {len(default_content)} 字符")
|
||||
except Exception as e:
|
||||
print(f"✗ 获取默认提示词失败: {e}")
|
||||
|
||||
def test_async_meeting_service_signature():
|
||||
"""测试async_meeting_service的方法签名"""
|
||||
print("\n=== 测试3: 验证方法签名支持prompt_id参数 ===")
|
||||
|
||||
import inspect
|
||||
async_service = AsyncMeetingService()
|
||||
|
||||
# 检查start_summary_generation方法签名
|
||||
sig = inspect.signature(async_service.start_summary_generation)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params:
|
||||
print(f"✓ start_summary_generation 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
else:
|
||||
print(f"✗ start_summary_generation 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params}")
|
||||
|
||||
# 检查monitor_and_auto_summarize方法签名
|
||||
sig2 = inspect.signature(async_service.monitor_and_auto_summarize)
|
||||
params2 = list(sig2.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params2:
|
||||
print(f"✓ monitor_and_auto_summarize 方法支持 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
else:
|
||||
print(f"✗ monitor_and_auto_summarize 方法缺少 prompt_id 参数")
|
||||
print(f" 参数列表: {params2}")
|
||||
|
||||
def test_database_schema():
|
||||
"""测试数据库schema是否包含prompt_id列"""
|
||||
print("\n=== 测试4: 验证数据库schema ===")
|
||||
|
||||
try:
|
||||
with get_db_connection() as connection:
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# 检查llm_tasks表是否有prompt_id列
|
||||
cursor.execute("""
|
||||
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'llm_tasks'
|
||||
AND COLUMN_NAME = 'prompt_id'
|
||||
""")
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
print(f"✓ llm_tasks 表包含 prompt_id 列")
|
||||
print(f" 类型: {result['DATA_TYPE']}")
|
||||
print(f" 可空: {result['IS_NULLABLE']}")
|
||||
print(f" 默认值: {result['COLUMN_DEFAULT']}")
|
||||
else:
|
||||
print(f"✗ llm_tasks 表缺少 prompt_id 列")
|
||||
except Exception as e:
|
||||
print(f"✗ 数据库检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def test_api_endpoints():
|
||||
"""测试API端点定义"""
|
||||
print("\n=== 测试5: 验证API端点定义 ===")
|
||||
|
||||
try:
|
||||
from app.api.endpoints.meetings import GenerateSummaryRequest
|
||||
import inspect
|
||||
|
||||
# 检查GenerateSummaryRequest模型
|
||||
fields = GenerateSummaryRequest.__fields__
|
||||
|
||||
if 'prompt_id' in fields:
|
||||
print(f"✓ GenerateSummaryRequest 包含 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
else:
|
||||
print(f"✗ GenerateSummaryRequest 缺少 prompt_id 字段")
|
||||
print(f" 字段列表: {list(fields.keys())}")
|
||||
|
||||
# 检查audio_service.handle_audio_upload签名
|
||||
from app.services.audio_service import handle_audio_upload
|
||||
sig = inspect.signature(handle_audio_upload)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
if 'prompt_id' in params:
|
||||
print(f"✓ handle_audio_upload 方法支持 prompt_id 参数")
|
||||
else:
|
||||
print(f"✗ handle_audio_upload 方法缺少 prompt_id 参数")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ API端点检查失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("开始测试提示词模版选择功能")
|
||||
print("=" * 60)
|
||||
|
||||
# 运行所有测试
|
||||
prompts = test_get_active_prompts()
|
||||
test_get_task_prompt_with_id(prompts)
|
||||
test_async_meeting_service_signature()
|
||||
test_database_schema()
|
||||
test_api_endpoints()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
print("=" * 60)
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
from qiniu import Auth, put_file_v2, BucketManager
|
||||
|
||||
# Add app path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), 'app'))
|
||||
|
||||
from app.core.config import QINIU_ACCESS_KEY, QINIU_SECRET_KEY, QINIU_BUCKET, QINIU_DOMAIN
|
||||
|
||||
def test_qiniu_connection():
|
||||
print("=== 七牛云连接测试 ===")
|
||||
print(f"Access Key: {QINIU_ACCESS_KEY[:10]}...")
|
||||
print(f"Secret Key: {QINIU_SECRET_KEY[:10]}...")
|
||||
print(f"Bucket: {QINIU_BUCKET}")
|
||||
print(f"Domain: {QINIU_DOMAIN}")
|
||||
|
||||
# 创建认证对象
|
||||
q = Auth(QINIU_ACCESS_KEY, QINIU_SECRET_KEY)
|
||||
|
||||
# 测试1: 生成上传token
|
||||
try:
|
||||
key = "test/connection-test.txt"
|
||||
token = q.upload_token(QINIU_BUCKET, key, 3600)
|
||||
print(f"✓ Token生成成功: {token[:50]}...")
|
||||
except Exception as e:
|
||||
print(f"✗ Token生成失败: {e}")
|
||||
return False
|
||||
|
||||
# 测试2: 列举存储空间 (测试认证是否正确)
|
||||
try:
|
||||
bucket_manager = BucketManager(q)
|
||||
ret, eof, info = bucket_manager.list(QINIU_BUCKET, limit=100)
|
||||
print(f"✓ Bucket访问成功, status_code: {info.status_code}")
|
||||
if ret:
|
||||
print(f" 存储空间中有文件: {len(ret.get('items', []))} 个")
|
||||
except Exception as e:
|
||||
print(f"✗ Bucket访问失败: {e}")
|
||||
return False
|
||||
|
||||
# 测试3: 上传一个小文件
|
||||
test_file = "/Users/jiliu/工作/projects/imeeting/backend/uploads/result.json"
|
||||
if os.path.exists(test_file):
|
||||
try:
|
||||
key = "test/result1.json"
|
||||
token = q.upload_token(QINIU_BUCKET, key, 3600)
|
||||
ret, info = put_file_v2(token, key, test_file, version='v2')
|
||||
|
||||
print(f"上传结果:")
|
||||
print(f" ret: {ret}")
|
||||
print(f" status_code: {info.status_code}")
|
||||
print(f" text_body: {info.text_body}")
|
||||
print(f" url: {info.url}")
|
||||
print(f" req_id: {info.req_id}")
|
||||
print(f" x_log: {info.x_log}")
|
||||
|
||||
if info.status_code == 200:
|
||||
print("✓ 文件上传成功")
|
||||
url = f"http://{QINIU_DOMAIN}/{key}"
|
||||
print(f" 访问URL: {url}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ 文件上传失败: {info.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 文件上传异常: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
else:
|
||||
print(f"✗ 测试文件不存在: {test_file}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_qiniu_connection()
|
||||
if success:
|
||||
print("\n🎉 七牛云连接测试成功!")
|
||||
else:
|
||||
print("\n❌ 七牛云连接测试失败!")
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Redis JWT Token 验证脚本
|
||||
用于检查JWT token是否正确存储在Redis中
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate # 激活虚拟环境
|
||||
python test/test_redis_jwt.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import redis
|
||||
import json
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from app.core.config import REDIS_CONFIG
|
||||
print("✅ 成功导入项目配置")
|
||||
except ImportError as e:
|
||||
print(f"❌ 导入项目配置失败: {e}")
|
||||
print("请确保在 backend 目录下运行: python test/test_redis_jwt.py")
|
||||
sys.exit(1)
|
||||
|
||||
def check_jwt_in_redis():
|
||||
"""检查Redis中的JWT token"""
|
||||
try:
|
||||
# 使用项目配置连接Redis
|
||||
r = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
# 测试连接
|
||||
r.ping()
|
||||
print("✅ Redis连接成功")
|
||||
print(f"连接配置: {REDIS_CONFIG}")
|
||||
|
||||
# 获取所有token相关的keys
|
||||
token_keys = r.keys("token:*")
|
||||
|
||||
if not token_keys:
|
||||
print("❌ Redis中没有找到JWT token")
|
||||
print("提示: 请先通过前端登录以生成token")
|
||||
return False
|
||||
|
||||
print(f"✅ 找到 {len(token_keys)} 个token记录:")
|
||||
|
||||
for key in token_keys:
|
||||
# 解析key格式: token:user_id:jwt_token
|
||||
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
|
||||
parts = key_str.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
user_id = parts[1]
|
||||
token_preview = parts[2][:20] + "..."
|
||||
ttl = r.ttl(key)
|
||||
value = r.get(key)
|
||||
value_str = value.decode('utf-8') if isinstance(value, bytes) else value
|
||||
|
||||
print(f" - 用户ID: {user_id}")
|
||||
print(f" Token预览: {token_preview}")
|
||||
if ttl > 0:
|
||||
print(f" 剩余时间: {ttl}秒 ({ttl/3600:.1f}小时)")
|
||||
else:
|
||||
print(f" TTL: {ttl} (永不过期)" if ttl == -1 else f" TTL: {ttl} (已过期)")
|
||||
print(f" 状态: {value_str}")
|
||||
print()
|
||||
|
||||
return True
|
||||
|
||||
except redis.ConnectionError:
|
||||
print("❌ 无法连接到Redis服务器")
|
||||
print("请确保Redis服务正在运行:")
|
||||
print(" brew services start redis # macOS")
|
||||
print(" 或 redis-server # 直接启动")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 检查失败: {e}")
|
||||
return False
|
||||
|
||||
def test_token_operations():
|
||||
"""测试token操作"""
|
||||
try:
|
||||
r = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
print("\n=== Token操作测试 ===")
|
||||
|
||||
# 模拟创建token
|
||||
test_key = "token:999:test_token_12345"
|
||||
r.setex(test_key, 60, "active")
|
||||
print(f"✅ 创建测试token: {test_key}")
|
||||
|
||||
# 检查token存在
|
||||
if r.exists(test_key):
|
||||
print("✅ Token存在性验证通过")
|
||||
|
||||
# 检查TTL
|
||||
ttl = r.ttl(test_key)
|
||||
print(f"✅ Token TTL: {ttl}秒")
|
||||
|
||||
# 删除测试token
|
||||
r.delete(test_key)
|
||||
print("✅ 清理测试token")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Token操作测试失败: {e}")
|
||||
return False
|
||||
|
||||
def test_jwt_service():
|
||||
"""测试JWT服务"""
|
||||
try:
|
||||
from app.services.jwt_service import jwt_service
|
||||
|
||||
print("\n=== JWT服务测试 ===")
|
||||
|
||||
# 测试创建token
|
||||
test_data = {
|
||||
"user_id": 999,
|
||||
"username": "test_user",
|
||||
"caption": "测试用户"
|
||||
}
|
||||
|
||||
token = jwt_service.create_access_token(test_data)
|
||||
print(f"✅ 创建JWT token: {token[:30]}...")
|
||||
|
||||
# 测试验证token
|
||||
payload = jwt_service.verify_token(token)
|
||||
if payload:
|
||||
print(f"✅ Token验证成功: 用户ID={payload['user_id']}, 用户名={payload['username']}")
|
||||
else:
|
||||
print("❌ Token验证失败")
|
||||
return False
|
||||
|
||||
# 测试撤销token
|
||||
revoked = jwt_service.revoke_token(token, test_data["user_id"])
|
||||
print(f"✅ 撤销token: {'成功' if revoked else '失败'}")
|
||||
|
||||
# 验证撤销后token失效
|
||||
payload_after_revoke = jwt_service.verify_token(token)
|
||||
if not payload_after_revoke:
|
||||
print("✅ Token撤销后验证失败,符合预期")
|
||||
else:
|
||||
print("❌ Token撤销后仍然有效,不符合预期")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ JWT服务测试失败: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("JWT + Redis 认证系统测试")
|
||||
print("=" * 50)
|
||||
print(f"工作目录: {os.getcwd()}")
|
||||
print(f"测试脚本路径: {__file__}")
|
||||
|
||||
# 检查Redis中的JWT tokens
|
||||
redis_ok = check_jwt_in_redis()
|
||||
|
||||
# 测试token操作
|
||||
operations_ok = test_token_operations()
|
||||
|
||||
# 测试JWT服务
|
||||
jwt_service_ok = test_jwt_service()
|
||||
|
||||
print("=" * 50)
|
||||
if redis_ok and operations_ok and jwt_service_ok:
|
||||
print("✅ JWT + Redis 认证系统工作正常!")
|
||||
else:
|
||||
print("❌ JWT + Redis 认证系统存在问题")
|
||||
print("\n故障排除建议:")
|
||||
print("1. 确保在 backend 目录下运行测试")
|
||||
print("2. 确保Redis服务正在运行")
|
||||
print("3. 确保已安装所有依赖: pip install -r requirements.txt")
|
||||
print("4. 尝试先通过前端登录生成token")
|
||||
sys.exit(1)
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试Redis连接和LLM任务队列
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import redis
|
||||
from app.core.config import REDIS_CONFIG
|
||||
|
||||
# 连接Redis
|
||||
redis_client = redis.Redis(**REDIS_CONFIG)
|
||||
|
||||
try:
|
||||
# 测试连接
|
||||
redis_client.ping()
|
||||
print("✅ Redis连接成功")
|
||||
|
||||
# 检查任务队列
|
||||
queue_length = redis_client.llen("llm_task_queue")
|
||||
print(f"📋 当前任务队列长度: {queue_length}")
|
||||
|
||||
# 检查所有LLM任务
|
||||
keys = redis_client.keys("llm_task:*")
|
||||
print(f"📊 当前存在的LLM任务: {len(keys)} 个")
|
||||
|
||||
for key in keys:
|
||||
task_data = redis_client.hgetall(key)
|
||||
# key可能是bytes或str
|
||||
if isinstance(key, bytes):
|
||||
task_id = key.decode('utf-8').replace('llm_task:', '')
|
||||
else:
|
||||
task_id = key.replace('llm_task:', '')
|
||||
|
||||
# 获取状态和进度
|
||||
status = task_data.get(b'status', task_data.get('status', 'unknown'))
|
||||
if isinstance(status, bytes):
|
||||
status = status.decode('utf-8')
|
||||
|
||||
progress = task_data.get(b'progress', task_data.get('progress', '0'))
|
||||
if isinstance(progress, bytes):
|
||||
progress = progress.decode('utf-8')
|
||||
|
||||
print(f" - 任务 {task_id[:8]}... 状态: {status}, 进度: {progress}%")
|
||||
|
||||
# 如果任务是pending,重新推送到队列
|
||||
if status == 'pending':
|
||||
print(f" 🔄 发现pending任务,重新推送到队列...")
|
||||
redis_client.lpush("llm_task_queue", task_id)
|
||||
print(f" ✅ 任务 {task_id[:8]}... 已重新推送到队列")
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
print(f"❌ Redis连接失败: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试流式LLM服务
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
|
||||
from app.services.llm_service import LLMService
|
||||
|
||||
def test_stream_generation():
|
||||
"""测试流式生成功能"""
|
||||
print("=== 测试流式LLM生成 ===")
|
||||
|
||||
llm_service = LLMService()
|
||||
test_meeting_id = 38 # 使用一个存在的会议ID
|
||||
test_user_prompt = "请重点关注决策事项和待办任务"
|
||||
|
||||
print(f"开始为会议 {test_meeting_id} 生成流式总结...")
|
||||
print("输出内容:")
|
||||
print("-" * 50)
|
||||
|
||||
full_content = ""
|
||||
chunk_count = 0
|
||||
|
||||
try:
|
||||
for chunk in llm_service.generate_meeting_summary_stream(test_meeting_id, test_user_prompt):
|
||||
if chunk.startswith("error:"):
|
||||
print(f"\n生成过程中出现错误: {chunk}")
|
||||
break
|
||||
else:
|
||||
print(chunk, end='', flush=True)
|
||||
full_content += chunk
|
||||
chunk_count += 1
|
||||
|
||||
print(f"\n\n-" * 50)
|
||||
print(f"流式生成完成!")
|
||||
print(f"总共接收到 {chunk_count} 个数据块")
|
||||
print(f"完整内容长度: {len(full_content)} 字符")
|
||||
|
||||
# 测试传统方式(对比)
|
||||
print("\n=== 对比测试传统生成方式 ===")
|
||||
result = llm_service.generate_meeting_summary(test_meeting_id, test_user_prompt)
|
||||
if result.get("error"):
|
||||
print(f"传统方式生成失败: {result['error']}")
|
||||
else:
|
||||
print("传统方式生成成功!")
|
||||
print(f"内容长度: {len(result['content'])} 字符")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n测试过程中出现异常: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_stream_generation()
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
JWT Token 过期测试脚本
|
||||
用于测试JWT token的过期、撤销机制,可以模拟指定用户的token失效
|
||||
|
||||
运行方法:
|
||||
cd /Users/jiliu/工作/projects/imeeting/backend
|
||||
source venv/bin/activate # 激活虚拟环境
|
||||
python test/test_token_expiration.py
|
||||
|
||||
功能:
|
||||
1. 登录指定用户并获取token
|
||||
2. 验证token有效性
|
||||
3. 撤销指定用户的所有token(模拟失效)
|
||||
4. 验证撤销后token失效
|
||||
|
||||
期望结果:在网页上登录的用户执行失效命令后,网页会自动登出
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
# 禁用代理以避免本地请求被代理
|
||||
PROXIES = {'http': None, 'https': None}
|
||||
|
||||
def invalidate_user_tokens():
|
||||
"""模拟指定用户的token失效"""
|
||||
print("模拟用户Token失效工具")
|
||||
print("=" * 40)
|
||||
|
||||
# 获取要失效的用户名
|
||||
target_username = input("请输入要失效token的用户名 (默认: mula): ").strip()
|
||||
if not target_username:
|
||||
target_username = "mula"
|
||||
|
||||
# 获取管理员凭据来执行失效操作
|
||||
admin_username = input("请输入管理员用户名 (默认: mula): ").strip()
|
||||
admin_password = input("请输入管理员密码 (默认: 781126): ").strip()
|
||||
|
||||
if not admin_username:
|
||||
admin_username = "mula"
|
||||
if not admin_password:
|
||||
admin_password = "781126"
|
||||
|
||||
try:
|
||||
# 1. 管理员登录获取token
|
||||
print(f"\n步骤1: 管理员登录 ({admin_username})")
|
||||
admin_login_data = {
|
||||
"username": admin_username,
|
||||
"password": admin_password
|
||||
}
|
||||
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=admin_login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 管理员登录失败")
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
return
|
||||
|
||||
admin_data = response.json()
|
||||
admin_token = admin_data["token"]
|
||||
admin_headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
print(f"✅ 管理员登录成功: {admin_data['username']} ({admin_data['caption']})")
|
||||
|
||||
# 2. 如果目标用户不是管理员,先登录目标用户验证token存在
|
||||
if target_username != admin_username:
|
||||
print(f"\n步骤2: 验证目标用户 ({target_username}) 是否存在")
|
||||
target_password = input(f"请输入 {target_username} 的密码 (用于验证): ").strip()
|
||||
if not target_password:
|
||||
print("❌ 需要提供目标用户的密码来验证")
|
||||
return
|
||||
|
||||
target_login_data = {
|
||||
"username": target_username,
|
||||
"password": target_password
|
||||
}
|
||||
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=target_login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 目标用户登录失败,无法验证用户存在")
|
||||
return
|
||||
|
||||
target_data = response.json()
|
||||
print(f"✅ 目标用户验证成功: {target_data['username']} ({target_data['caption']})")
|
||||
target_user_id = target_data['user_id']
|
||||
else:
|
||||
target_user_id = admin_data['user_id']
|
||||
|
||||
# 3. 撤销目标用户的所有token
|
||||
print(f"\n步骤3: 撤销用户 {target_username} (ID: {target_user_id}) 的所有token")
|
||||
|
||||
# 使用管理员权限调用新的admin API
|
||||
response = requests.post(f"{BASE_URL}/api/auth/admin/revoke-user-tokens/{target_user_id}",
|
||||
headers=admin_headers, proxies=PROXIES)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"✅ Token撤销成功: {result.get('message', '已撤销所有token')}")
|
||||
|
||||
# 4. 验证token是否真的失效了
|
||||
print(f"\n步骤4: 验证token失效")
|
||||
if target_username != admin_username:
|
||||
# 尝试使用目标用户的token访问protected API
|
||||
target_token = target_data["token"]
|
||||
target_headers = {"Authorization": f"Bearer {target_token}"}
|
||||
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=target_headers, proxies=PROXIES)
|
||||
if response.status_code == 401:
|
||||
print(f"✅ 验证成功:用户 {target_username} 的token已失效")
|
||||
else:
|
||||
print(f"❌ 验证失败:用户 {target_username} 的token仍然有效")
|
||||
else:
|
||||
# 如果目标用户就是管理员,验证当前管理员token是否失效
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=admin_headers, proxies=PROXIES)
|
||||
if response.status_code == 401:
|
||||
print(f"✅ 验证成功:用户 {target_username} 的token已失效")
|
||||
else:
|
||||
print(f"❌ 验证失败:用户 {target_username} 的token仍然有效")
|
||||
|
||||
print(f"\n🌟 操作完成!")
|
||||
print(f"如果用户 {target_username} 在网页上已登录,现在应该会自动登出。")
|
||||
print(f"你可以在网页上验证是否自动跳转到登录页面。")
|
||||
|
||||
else:
|
||||
print(f"❌ Token撤销失败: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 无法连接到后端服务器,请确保服务器正在运行")
|
||||
except Exception as e:
|
||||
print(f"❌ 操作失败: {e}")
|
||||
|
||||
def test_token_expiration():
|
||||
"""测试token过期机制"""
|
||||
print("JWT Token 过期测试")
|
||||
print("=" * 40)
|
||||
|
||||
# 1. 登录获取token
|
||||
username = input("请输入用户名 (默认: test): ").strip()
|
||||
password = input("请输入密码 (默认: test): ").strip()
|
||||
|
||||
# 使用默认值如果输入为空
|
||||
if not username:
|
||||
username = "test"
|
||||
if not password:
|
||||
password = "test"
|
||||
|
||||
login_data = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
|
||||
try:
|
||||
# 登录
|
||||
print(f"正在尝试登录用户: {login_data['username']}")
|
||||
response = requests.post(f"{BASE_URL}/api/auth/login", json=login_data, proxies=PROXIES)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 登录失败")
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
print(f"请求URL: {BASE_URL}/api/auth/login")
|
||||
print("请检查:")
|
||||
print("1. 后端服务是否正在运行")
|
||||
print("2. 用户名和密码是否正确")
|
||||
print("3. 数据库连接是否正常")
|
||||
return
|
||||
|
||||
user_data = response.json()
|
||||
token = user_data["token"]
|
||||
|
||||
print(f"✅ 登录成功,获得token: {token[:20]}...")
|
||||
|
||||
# 2. 测试token有效性
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
print("\n测试1: 验证token有效性")
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 200:
|
||||
user_info = response.json()
|
||||
print(f"✅ Token有效,用户: {user_info.get('username')}")
|
||||
else:
|
||||
print(f"❌ Token无效: {response.status_code}")
|
||||
return
|
||||
|
||||
# 3. 测试受保护的API
|
||||
print("\n测试2: 访问受保护的API")
|
||||
response = requests.get(f"{BASE_URL}/api/meetings", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 200:
|
||||
print("✅ 成功访问会议列表API")
|
||||
else:
|
||||
print(f"❌ 访问受保护API失败: {response.status_code}")
|
||||
|
||||
# 4. 登出token
|
||||
print("\n测试3: 登出token")
|
||||
response = requests.post(f"{BASE_URL}/api/auth/logout", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 200:
|
||||
print("✅ 登出成功")
|
||||
|
||||
# 5. 验证登出后token失效
|
||||
print("\n测试4: 验证登出后token失效")
|
||||
response = requests.get(f"{BASE_URL}/api/auth/me", headers=headers, proxies=PROXIES)
|
||||
if response.status_code == 401:
|
||||
print("✅ Token已失效,登出成功")
|
||||
else:
|
||||
print(f"❌ Token仍然有效,登出失败: {response.status_code}")
|
||||
else:
|
||||
print(f"❌ 登出失败: {response.status_code}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("❌ 无法连接到后端服务器,请确保服务器正在运行")
|
||||
except Exception as e:
|
||||
print(f"❌ 测试失败: {e}")
|
||||
|
||||
def check_token_format():
|
||||
"""检查token格式是否为JWT"""
|
||||
token = input("\n请粘贴JWT token (或按Enter跳过): ").strip()
|
||||
|
||||
if not token:
|
||||
return
|
||||
|
||||
print(f"\nJWT格式检查:")
|
||||
|
||||
# JWT应该有三个部分,用.分隔
|
||||
parts = token.split('.')
|
||||
if len(parts) != 3:
|
||||
print("❌ 不是有效的JWT格式 (应该有3个部分用.分隔)")
|
||||
return
|
||||
|
||||
try:
|
||||
import base64
|
||||
import json
|
||||
|
||||
# 解码header
|
||||
header_padding = parts[0] + '=' * (4 - len(parts[0]) % 4)
|
||||
header = json.loads(base64.urlsafe_b64decode(header_padding))
|
||||
|
||||
# 解码payload
|
||||
payload_padding = parts[1] + '=' * (4 - len(parts[1]) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(payload_padding))
|
||||
|
||||
print("✅ JWT格式有效")
|
||||
print(f"算法: {header.get('alg')}")
|
||||
print(f"类型: {header.get('typ')}")
|
||||
print(f"用户ID: {payload.get('user_id')}")
|
||||
print(f"用户名: {payload.get('username')}")
|
||||
|
||||
if 'exp' in payload:
|
||||
exp_time = datetime.fromtimestamp(payload['exp'])
|
||||
print(f"过期时间: {exp_time}")
|
||||
|
||||
if datetime.now() > exp_time:
|
||||
print("❌ Token已过期")
|
||||
else:
|
||||
print("✅ Token未过期")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ JWT解码失败: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("JWT Token 测试工具")
|
||||
print("=" * 50)
|
||||
print(f"工作目录: {os.getcwd()}")
|
||||
print(f"测试脚本路径: {__file__}")
|
||||
print()
|
||||
|
||||
print("请选择功能:")
|
||||
print("1. 模拟指定用户Token失效 (推荐)")
|
||||
print("2. 完整Token过期测试")
|
||||
print("3. JWT格式检查")
|
||||
|
||||
choice = input("\n请输入选项 (1-3, 默认: 1): ").strip()
|
||||
|
||||
if choice == "2":
|
||||
test_token_expiration()
|
||||
check_token_format()
|
||||
elif choice == "3":
|
||||
check_token_format()
|
||||
else:
|
||||
# 默认选择1
|
||||
invalidate_user_tokens()
|
||||
|
||||
print("\n=== 测试完成 ===")
|
||||
print("如果测试失败,请检查:")
|
||||
print("1. 确保后端服务正在运行: python main.py")
|
||||
print("2. 确保在 backend 目录下运行测试")
|
||||
print("3. 确保Redis服务正在运行")
|
||||
print("4. 如果选择了选项1,请在网页上验证用户是否自动登出")
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
"""
|
||||
测试 upload_audio 接口的 auto_summarize 参数
|
||||
"""
|
||||
import requests
|
||||
import time
|
||||
import json
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://localhost:8000/api"
|
||||
# 请替换为你的有效token
|
||||
AUTH_TOKEN = "your_auth_token_here"
|
||||
# 请替换为你的测试会议ID
|
||||
TEST_MEETING_ID = 1
|
||||
|
||||
# 请求头
|
||||
headers = {
|
||||
"Authorization": f"Bearer {AUTH_TOKEN}"
|
||||
}
|
||||
|
||||
|
||||
def test_upload_audio(auto_summarize=True):
|
||||
"""测试音频上传接口"""
|
||||
print("=" * 60)
|
||||
print(f"测试: upload_audio 接口 (auto_summarize={auto_summarize})")
|
||||
print("=" * 60)
|
||||
|
||||
# 准备测试文件
|
||||
audio_file_path = "test_audio.mp3" # 请替换为实际的音频文件路径
|
||||
|
||||
try:
|
||||
with open(audio_file_path, 'rb') as audio_file:
|
||||
files = {
|
||||
'audio_file': ('test_audio.mp3', audio_file, 'audio/mpeg')
|
||||
}
|
||||
data = {
|
||||
'force_replace': 'false',
|
||||
'auto_summarize': 'true' if auto_summarize else 'false'
|
||||
}
|
||||
|
||||
# 发送请求
|
||||
url = f"{BASE_URL}/meetings/upload-audio"
|
||||
print(f"\n发送请求到: {url}")
|
||||
print(f"参数: auto_summarize={data['auto_summarize']}")
|
||||
response = requests.post(url, headers=headers, files=files, data=data)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容:")
|
||||
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||
|
||||
# 如果上传成功,获取任务ID
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if response_data.get('code') == '200':
|
||||
task_id = response_data['data'].get('task_id')
|
||||
auto_sum = response_data['data'].get('auto_summarize')
|
||||
print(f"\n✓ 上传成功! 转录任务ID: {task_id}")
|
||||
print(f" 自动总结: {'开启' if auto_sum else '关闭'}")
|
||||
if auto_sum:
|
||||
print(f" 提示: 音频已上传,后台正在自动进行转录和总结")
|
||||
else:
|
||||
print(f" 提示: 音频已上传,正在进行转录(不会自动总结)")
|
||||
print(f"\n 可以通过以下接口查询状态:")
|
||||
print(f" - 转录状态: GET /meetings/{TEST_MEETING_ID}/transcription/status")
|
||||
print(f" - 总结任务: GET /meetings/{TEST_MEETING_ID}/llm-tasks")
|
||||
print(f" - 会议详情: GET /meetings/{TEST_MEETING_ID}")
|
||||
return True
|
||||
elif response_data.get('code') == '300':
|
||||
print("\n⚠ 需要确认替换现有文件")
|
||||
return False
|
||||
else:
|
||||
print(f"\n✗ 上传失败")
|
||||
return False
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"\n✗ 错误: 找不到测试音频文件 {audio_file_path}")
|
||||
print("请创建一个测试音频文件或修改 audio_file_path 变量")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"\n✗ 错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_get_transcription_status():
|
||||
"""测试获取转录状态接口"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试: 获取转录状态")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{BASE_URL}/meetings/{TEST_MEETING_ID}/transcription/status"
|
||||
print(f"\n发送请求到: {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容:")
|
||||
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if response_data.get('code') == '200':
|
||||
data = response_data['data']
|
||||
print(f"\n✓ 获取转录状态成功!")
|
||||
print(f" - 任务ID: {data.get('task_id')}")
|
||||
print(f" - 状态: {data.get('status')}")
|
||||
print(f" - 进度: {data.get('progress')}%")
|
||||
return data.get('status'), data.get('progress')
|
||||
else:
|
||||
print(f"\n✗ 获取状态失败")
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 错误: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def test_get_llm_tasks():
|
||||
"""测试获取LLM任务列表"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试: 获取LLM任务列表")
|
||||
print("=" * 60)
|
||||
|
||||
url = f"{BASE_URL}/meetings/{TEST_MEETING_ID}/llm-tasks"
|
||||
print(f"\n发送请求到: {url}")
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应内容:")
|
||||
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if response_data.get('code') == '200':
|
||||
tasks = response_data['data'].get('tasks', [])
|
||||
print(f"\n✓ 获取LLM任务成功! 共 {len(tasks)} 个任务")
|
||||
if tasks:
|
||||
latest_task = tasks[0]
|
||||
print(f" 最新任务:")
|
||||
print(f" - 任务ID: {latest_task.get('task_id')}")
|
||||
print(f" - 状态: {latest_task.get('status')}")
|
||||
print(f" - 进度: {latest_task.get('progress')}%")
|
||||
return latest_task.get('status'), latest_task.get('progress')
|
||||
return None, None
|
||||
else:
|
||||
print(f"\n✗ 获取任务失败")
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 错误: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def monitor_progress():
|
||||
"""持续监控处理进度"""
|
||||
print("\n" + "=" * 60)
|
||||
print("持续监控处理进度 (每10秒查询一次)")
|
||||
print("按 Ctrl+C 停止监控")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
transcription_completed = False
|
||||
summary_completed = False
|
||||
|
||||
while True:
|
||||
print(f"\n[{time.strftime('%H:%M:%S')}] 查询状态...")
|
||||
|
||||
# 查询转录状态
|
||||
trans_status, trans_progress = test_get_transcription_status()
|
||||
|
||||
# 如果转录完成,查询总结状态
|
||||
if trans_status == 'completed' and not transcription_completed:
|
||||
print(f"\n✓ 转录已完成!")
|
||||
transcription_completed = True
|
||||
|
||||
if transcription_completed:
|
||||
summ_status, summ_progress = test_get_llm_tasks()
|
||||
if summ_status == 'completed' and not summary_completed:
|
||||
print(f"\n✓ 总结已完成!")
|
||||
summary_completed = True
|
||||
break
|
||||
elif summ_status == 'failed':
|
||||
print(f"\n✗ 总结失败")
|
||||
break
|
||||
|
||||
# 检查转录是否失败
|
||||
if trans_status == 'failed':
|
||||
print(f"\n✗ 转录失败")
|
||||
break
|
||||
|
||||
# 如果全部完成,退出
|
||||
if transcription_completed and summary_completed:
|
||||
print(f"\n✓ 全部完成!")
|
||||
break
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠ 用户中断监控")
|
||||
except Exception as e:
|
||||
print(f"\n✗ 监控出错: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("\n")
|
||||
print("╔" + "═" * 58 + "╗")
|
||||
print("║" + " " * 12 + "upload_audio 接口测试" + " " * 23 + "║")
|
||||
print("║" + " " * 10 + "(测试 auto_summarize 参数)" + " " * 17 + "║")
|
||||
print("╚" + "═" * 58 + "╝")
|
||||
|
||||
print("\n请确保:")
|
||||
print("1. 后端服务正在运行 (http://localhost:8000)")
|
||||
print("2. 已修改脚本中的 AUTH_TOKEN 和 TEST_MEETING_ID")
|
||||
print("3. 已准备好测试音频文件")
|
||||
|
||||
input("\n按回车键开始测试...")
|
||||
|
||||
# 测试1: 查看当前转录状态
|
||||
test_get_transcription_status()
|
||||
|
||||
# 测试2: 查看当前LLM任务
|
||||
test_get_llm_tasks()
|
||||
|
||||
# 询问要测试哪种模式
|
||||
print("\n" + "-" * 60)
|
||||
print("请选择测试模式:")
|
||||
print("1. 仅转录 (auto_summarize=false)")
|
||||
print("2. 转录+自动总结 (auto_summarize=true)")
|
||||
print("3. 两种模式都测试")
|
||||
choice = input("请输入选项 (1/2/3): ")
|
||||
|
||||
if choice == '1':
|
||||
# 测试:仅转录
|
||||
if test_upload_audio(auto_summarize=False):
|
||||
print("\n⚠ 注意: 此模式下不会自动生成总结")
|
||||
print("如需生成总结,请手动调用: POST /meetings/{meeting_id}/generate-summary-async")
|
||||
elif choice == '2':
|
||||
# 测试:转录+自动总结
|
||||
if test_upload_audio(auto_summarize=True):
|
||||
print("\n" + "-" * 60)
|
||||
choice = input("是否要持续监控处理进度? (y/n): ")
|
||||
if choice.lower() == 'y':
|
||||
monitor_progress()
|
||||
elif choice == '3':
|
||||
# 两种模式都测试
|
||||
print("\n" + "=" * 60)
|
||||
print("测试模式1: 仅转录 (auto_summarize=false)")
|
||||
print("=" * 60)
|
||||
test_upload_audio(auto_summarize=False)
|
||||
|
||||
input("\n按回车键继续测试模式2...")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试模式2: 转录+自动总结 (auto_summarize=true)")
|
||||
print("=" * 60)
|
||||
if test_upload_audio(auto_summarize=True):
|
||||
print("\n" + "-" * 60)
|
||||
choice = input("是否要持续监控处理进度? (y/n): ")
|
||||
if choice.lower() == 'y':
|
||||
monitor_progress()
|
||||
else:
|
||||
print("\n✗ 无效选项")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成!")
|
||||
print("=" * 60)
|
||||
print("\n总结:")
|
||||
print("- auto_summarize=false: 只执行转录,不自动生成总结")
|
||||
print("- auto_summarize=true: 执行转录后自动生成总结")
|
||||
print("- 默认值: true (向前兼容)")
|
||||
print("- 现有页面建议设置: auto_summarize=false")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
"""
|
||||
声纹采集API测试脚本
|
||||
|
||||
使用方法:
|
||||
1. 确保后端服务正在运行
|
||||
2. 修改 USER_ID 和 TOKEN 为实际值
|
||||
3. 准备一个10秒的WAV音频文件
|
||||
4. 运行: python test_voiceprint_api.py
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://localhost:8000/api"
|
||||
USER_ID = 1 # 修改为实际用户ID
|
||||
TOKEN = "" # 登录后获取的token
|
||||
|
||||
# 请求头
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
|
||||
def test_get_template():
|
||||
"""测试获取朗读模板"""
|
||||
print("\n=== 测试1: 获取朗读模板 ===")
|
||||
url = f"{BASE_URL}/voiceprint/template"
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_get_status(user_id):
|
||||
"""测试获取声纹状态"""
|
||||
print(f"\n=== 测试2: 获取用户 {user_id} 的声纹状态 ===")
|
||||
url = f"{BASE_URL}/voiceprint/{user_id}"
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_upload_voiceprint(user_id, audio_file_path):
|
||||
"""测试上传声纹"""
|
||||
print(f"\n=== 测试3: 上传声纹音频 ===")
|
||||
url = f"{BASE_URL}/voiceprint/{user_id}"
|
||||
|
||||
# 移除Content-Type,让requests自动设置multipart/form-data
|
||||
upload_headers = {
|
||||
"Authorization": f"Bearer {TOKEN}"
|
||||
}
|
||||
|
||||
with open(audio_file_path, 'rb') as f:
|
||||
files = {'audio_file': (audio_file_path.split('/')[-1], f, 'audio/wav')}
|
||||
response = requests.post(url, headers=upload_headers, files=files)
|
||||
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_delete_voiceprint(user_id):
|
||||
"""测试删除声纹"""
|
||||
print(f"\n=== 测试4: 删除用户 {user_id} 的声纹 ===")
|
||||
url = f"{BASE_URL}/voiceprint/{user_id}"
|
||||
response = requests.delete(url, headers=headers)
|
||||
print(f"状态码: {response.status_code}")
|
||||
print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||
return response.json()
|
||||
|
||||
|
||||
def login(username, password):
|
||||
"""登录获取token"""
|
||||
print("\n=== 登录获取Token ===")
|
||||
url = f"{BASE_URL}/auth/login"
|
||||
data = {
|
||||
"username": username,
|
||||
"password": password
|
||||
}
|
||||
response = requests.post(url, json=data)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get('code') == '200':
|
||||
token = result['data']['token']
|
||||
print(f"登录成功,Token: {token[:20]}...")
|
||||
return token
|
||||
else:
|
||||
print(f"登录失败: {result.get('message')}")
|
||||
return None
|
||||
else:
|
||||
print(f"请求失败,状态码: {response.status_code}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("声纹采集API测试脚本")
|
||||
print("=" * 60)
|
||||
|
||||
# 步骤1: 登录(如果没有token)
|
||||
if not TOKEN:
|
||||
print("\n请先登录获取Token...")
|
||||
username = input("用户名: ")
|
||||
password = input("密码: ")
|
||||
TOKEN = login(username, password)
|
||||
if TOKEN:
|
||||
headers["Authorization"] = f"Bearer {TOKEN}"
|
||||
else:
|
||||
print("登录失败,退出测试")
|
||||
exit(1)
|
||||
|
||||
# 步骤2: 测试获取朗读模板
|
||||
test_get_template()
|
||||
|
||||
# 步骤3: 测试获取声纹状态
|
||||
test_get_status(USER_ID)
|
||||
|
||||
# 步骤4: 测试上传声纹(需要准备音频文件)
|
||||
audio_file = input("\n请输入WAV音频文件路径 (回车跳过上传测试): ")
|
||||
if audio_file.strip():
|
||||
test_upload_voiceprint(USER_ID, audio_file.strip())
|
||||
|
||||
# 上传后再次查看状态
|
||||
print("\n=== 上传后再次查看状态 ===")
|
||||
test_get_status(USER_ID)
|
||||
|
||||
# 步骤5: 测试删除声纹
|
||||
confirm = input("\n是否测试删除声纹? (yes/no): ")
|
||||
if confirm.lower() == 'yes':
|
||||
test_delete_voiceprint(USER_ID)
|
||||
|
||||
# 删除后再次查看状态
|
||||
print("\n=== 删除后再次查看状态 ===")
|
||||
test_get_status(USER_ID)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
print("=" * 60)
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试worker线程是否正常工作
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.services.async_meeting_service import AsyncMeetingService
|
||||
|
||||
# 创建服务实例
|
||||
service = AsyncMeetingService()
|
||||
|
||||
# 直接调用处理任务方法测试
|
||||
print("测试直接调用_process_tasks方法...")
|
||||
|
||||
# 设置worker_running为True
|
||||
service.worker_running = True
|
||||
|
||||
# 创建线程并启动
|
||||
thread = threading.Thread(target=service._process_tasks)
|
||||
thread.daemon = False # 不设置为daemon,确保能看到输出
|
||||
thread.start()
|
||||
|
||||
print(f"线程是否活动: {thread.is_alive()}")
|
||||
print("等待5秒...")
|
||||
|
||||
# 等待一段时间
|
||||
time.sleep(5)
|
||||
|
||||
# 停止worker
|
||||
service.worker_running = False
|
||||
thread.join(timeout=10)
|
||||
|
||||
print("测试完成")
|
||||
|
|
@ -176,10 +176,11 @@
|
|||
| `task_id` | VARCHAR(100) | PRIMARY KEY | 业务任务唯一ID (UUID) |
|
||||
| `meeting_id` | INT | NOT NULL, FK | 关联的会议ID |
|
||||
| `prompt_id` | INT | NOT NULL, FK | 关联的提示词模版ID |
|
||||
| `model_code` | VARCHAR(100) | NULL | 本次任务使用的模型编码,用于服务恢复和任务重试时复用原模型 |
|
||||
| `user_prompt` | TEXT | NULL | 用户输入的额外提示 |
|
||||
| `status` | ENUM(...) | DEFAULT 'pending' | 任务状态: 'pending', 'processing', 'completed', 'failed' |
|
||||
| `progress` | INT | DEFAULT 0 | 任务进度百分比 (0-100) |
|
||||
| `result` | TEXT | NULL | 成功时存储生成的纪要内容 |
|
||||
| `result` | TEXT | NULL | 成功时存储导出的 Markdown 文件路径(如 `/uploads/...`) |
|
||||
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 任务创建时间 |
|
||||
| `completed_at` | TIMESTAMP | NULL | 任务完成时间 |
|
||||
| `error_message` | TEXT | NULL | 错误信息记录 |
|
||||
|
|
@ -433,4 +434,4 @@ erDiagram
|
|||
meetings ||--|{ meeting_summaries : "has"
|
||||
meetings ||--|{ llm_tasks : "has"
|
||||
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -2,4 +2,11 @@
|
|||
|
||||
# 组件
|
||||
+ 数据库 mysql 5.7+ 10.100.51.51:3306 root | Unis@123
|
||||
+ 缓存 redis 6.2 10.100.51.51:6379 Unis@123
|
||||
+ 缓存 redis 6.2 10.100.51.51:6379 Unis@123
|
||||
|
||||
# 升级前确认
|
||||
+ 后端运行环境需提供 `ffmpeg` 与 `ffprobe`
|
||||
+ 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql`
|
||||
+ 本次数据库升级还需执行 `backend/sql/migrations/normalize_llm_task_result_paths.sql`
|
||||
+ 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列
|
||||
+ 升级后 `llm_tasks.result` 将统一为 `/uploads/...` 相对路径,与音频文件路径保持一致
|
||||
|
|
|
|||
|
|
@ -0,0 +1,499 @@
|
|||
# 通用代码结构设计规范(强制执行)
|
||||
|
||||
本文档定义项目在长期演进中应遵守的结构边界、拆分原则与审计标准。
|
||||
|
||||
目标不是机械追求“小文件”“多目录”或“某种固定架构”,而是让任意语言、任意框架、任意部署形态下的代码都满足以下要求:
|
||||
|
||||
- 入口清晰
|
||||
- 依赖方向稳定
|
||||
- 职责边界明确
|
||||
- 修改影响面可控
|
||||
- 新人可顺序读懂
|
||||
|
||||
本文档适用于:
|
||||
|
||||
- 前端应用
|
||||
- 后端服务
|
||||
- CLI / 脚本 / Worker
|
||||
- SDK / Library
|
||||
- 单仓或多仓项目
|
||||
|
||||
本文档自落地起作为后续开发与重构的默认结构基线。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心原则
|
||||
|
||||
### 1.1 先划清职责,再决定目录
|
||||
|
||||
- 先区分“入口层 / 业务编排层 / 领域规则层 / 基础设施层 / 共享基础层”,再决定是否拆目录、拆文件、拆包。
|
||||
- 目录结构是职责设计的结果,不是先验答案。
|
||||
- 同一个团队可以采用不同目录形态,但不能模糊职责边界。
|
||||
|
||||
### 1.2 领域内聚优先于机械拆分
|
||||
|
||||
- 第一判断标准是“是否仍然属于同一业务主题”,不是“文件还能不能再拆小”。
|
||||
- 同一主题内的读取、写入、校验、少量派生逻辑,可以保留在同一模块中。
|
||||
- 如果拆分只会制造更多跳转、隐藏真实依赖、降低顺序可读性,就不应继续拆。
|
||||
|
||||
### 1.3 装配层必须薄
|
||||
|
||||
- 启动入口、路由入口、页面入口、命令入口都只负责装配。
|
||||
- 装配层可以做依赖注入、参数收集、状态接线、组件拼装、调用编排入口。
|
||||
- 装配层不应承载复杂业务规则、数据库细节、文件系统细节、网络细节或长流程状态机。
|
||||
|
||||
### 1.4 副作用必须收口
|
||||
|
||||
- 数据库访问、文件读写、网络调用、缓存、定时器、浏览器存储、进程环境依赖等,都属于副作用。
|
||||
- 副作用应集中在可识别的边界模块中,不应在页面、视图、路由、DTO、纯工具里四处散落。
|
||||
- 任何需要 mock、替换、复用或测试隔离的外部依赖,都应有明确归属。
|
||||
|
||||
### 1.5 依赖方向必须单向
|
||||
|
||||
- 默认依赖方向应从外向内:入口层 -> 业务编排层 -> 领域规则层 -> 基础设施实现。
|
||||
- 共享基础层可以被多个层使用,但不能反向依赖业务实现。
|
||||
- 低层不能反向引用高层具体实现来“图省事”。
|
||||
|
||||
### 1.6 文件大小不是目标,跨职责才是风险
|
||||
|
||||
- 行数只作为预警信号,不作为强制拆分指标。
|
||||
- 真正需要拆分的信号包括:
|
||||
- 一个模块服务多个业务主题
|
||||
- 一个模块同时承担输入解析、业务决策、数据访问和展示
|
||||
- 一个改动常常需要在同一文件中切换多种关注点
|
||||
- 一个模块需要为不同调用方维持多套语义
|
||||
|
||||
### 1.7 重构优先低风险搬运
|
||||
|
||||
- 结构重构优先做“职责收口、边界清理、命名校正、依赖下沉/上提”。
|
||||
- 默认不要在同一轮改动里同时进行:
|
||||
- 大规模结构调整
|
||||
- 新功能开发
|
||||
- 行为修复
|
||||
- 如果确需并行,必须以最小范围控制风险,并显式验证关键路径。
|
||||
|
||||
### 1.8 命名必须体现主题
|
||||
|
||||
- 文件、目录、模块、类型、服务名都应直接表达责任。
|
||||
- 禁止使用模糊命名掩盖职责,例如:
|
||||
- `misc`
|
||||
- `helpers2`
|
||||
- `commonThing`
|
||||
- `temp_service`
|
||||
- `manager_new`
|
||||
|
||||
---
|
||||
|
||||
## 2. 通用分层模型
|
||||
|
||||
以下是跨语言可复用的职责模型。项目不要求逐字使用这些目录名,但必须能映射到这些边界。
|
||||
|
||||
### 2.1 入口层 / 接口层
|
||||
|
||||
典型形态:
|
||||
|
||||
- 前端的 `App`、路由入口、页面入口
|
||||
- 后端的 router / controller / handler
|
||||
- CLI 的 command / main
|
||||
- Worker 的 job handler / consumer entry
|
||||
|
||||
职责:
|
||||
|
||||
- 接收输入
|
||||
- 做基础参数解析与协议适配
|
||||
- 调用业务编排层
|
||||
- 返回结果或渲染输出
|
||||
|
||||
禁止:
|
||||
|
||||
- 写复杂业务规则
|
||||
- 直接拼装 SQL / ORM 流程
|
||||
- 直接进行大段文件系统读写
|
||||
- 直接进行复杂网络编排
|
||||
- 在入口层内维护长生命周期状态机
|
||||
|
||||
### 2.2 业务编排层 / 用例层
|
||||
|
||||
典型形态:
|
||||
|
||||
- service
|
||||
- use case
|
||||
- action
|
||||
- controller hook
|
||||
- page model
|
||||
- workflow
|
||||
|
||||
职责:
|
||||
|
||||
- 表达一个明确业务流程
|
||||
- 协调多个依赖
|
||||
- 承载事务边界、步骤顺序、状态推进
|
||||
- 组织权限判断、前置校验、错误分支
|
||||
|
||||
要求:
|
||||
|
||||
- 一个模块只负责一个业务域或一个稳定子流程
|
||||
- 可以依赖基础设施接口,但不应把具体协议细节暴露给上层
|
||||
- 可以包含少量私有 helper,但 helper 仅服务当前主题
|
||||
|
||||
### 2.3 领域规则层
|
||||
|
||||
典型形态:
|
||||
|
||||
- domain service
|
||||
- policy
|
||||
- rule
|
||||
- validator
|
||||
- entity behavior
|
||||
- pure business helpers
|
||||
|
||||
职责:
|
||||
|
||||
- 承载稳定的业务规则和领域语义
|
||||
- 保持尽量纯净、可测试、与外部协议解耦
|
||||
- 统一业务概念、状态转换、派生计算
|
||||
|
||||
要求:
|
||||
|
||||
- 不直接访问数据库、网络、文件、浏览器环境
|
||||
- 不依赖 UI、HTTP、CLI、消息队列等入口协议
|
||||
|
||||
### 2.4 基础设施层 / 数据访问层
|
||||
|
||||
典型形态:
|
||||
|
||||
- repository
|
||||
- gateway
|
||||
- API client
|
||||
- storage adapter
|
||||
- cache adapter
|
||||
- filesystem adapter
|
||||
- persistence implementation
|
||||
|
||||
职责:
|
||||
|
||||
- 封装外部系统细节
|
||||
- 处理数据库、缓存、HTTP、对象存储、消息队列、浏览器存储、操作系统能力
|
||||
- 提供可复用的边界接口
|
||||
|
||||
要求:
|
||||
|
||||
- 只解决“怎么接外部系统”,不承担业务决策
|
||||
- 协议转换、序列化、连接管理、重试策略等应在此层收口
|
||||
|
||||
### 2.5 共享基础层
|
||||
|
||||
典型形态:
|
||||
|
||||
- constants
|
||||
- shared types
|
||||
- date / string / number helpers
|
||||
- 通用 UI 基础组件
|
||||
- 通用错误定义
|
||||
|
||||
要求:
|
||||
|
||||
- 必须是真正跨域、稳定、低语义耦合的内容
|
||||
- 不允许把业务逻辑伪装成“common / shared / utils”
|
||||
- 一旦某模块开始依赖特定业务名词,它就不再是共享基础层
|
||||
|
||||
---
|
||||
|
||||
## 3. 目录与模块组织规范
|
||||
|
||||
### 3.1 允许的组织方式
|
||||
|
||||
项目可以采用以下任一方式:
|
||||
|
||||
- 按领域优先组织:`<domain>/<entry|application|infra|shared>`
|
||||
- 按层优先组织:`entry/ application/ domain/ infra/`
|
||||
- 混合组织:顶层按领域,领域内再分层
|
||||
- Monorepo 组织:`apps/ packages/ services/ workers/`
|
||||
|
||||
允许多种组织方式并存,但必须满足:
|
||||
|
||||
- 同一仓库内的同类代码遵循一致的判断逻辑
|
||||
- 每个模块都能被映射到明确职责层
|
||||
- 依赖方向清晰、稳定、可审计
|
||||
|
||||
### 3.2 推荐的判断方式
|
||||
|
||||
当你不确定某段代码应该放哪里时,按顺序判断:
|
||||
|
||||
1. 它是在接收输入、渲染输出、还是拼装启动吗
|
||||
2. 它是在表达一个完整业务流程吗
|
||||
3. 它是在表达不依赖外部协议的业务规则吗
|
||||
4. 它是在接数据库、文件、网络、缓存、浏览器或系统能力吗
|
||||
5. 它真的是跨域共享能力吗
|
||||
|
||||
### 3.3 一个模块只能有一个主语义
|
||||
|
||||
- 一个模块可以有多个函数,但只能服务一个主职责。
|
||||
- 如果一个文件既是“页面”又是“API 聚合器”又是“缓存控制器”又是“视图组件”,就已经越界。
|
||||
- 如果一个 router 文件开始长时间停留在 SQL、文件读写、事务与状态轮询细节上,也已经越界。
|
||||
|
||||
### 3.4 兼容层与过渡层
|
||||
|
||||
- 允许存在短期兼容层、导出层、适配层。
|
||||
- 兼容层必须被明确标记为过渡用途。
|
||||
- 禁止长期把新逻辑继续堆回兼容层。
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端通用规范
|
||||
|
||||
本节适用于 Web、桌面端、移动端和前端壳应用,不绑定 React / Vue / Svelte 等具体框架。
|
||||
|
||||
### 4.1 页面/路由入口必须薄
|
||||
|
||||
- 页面文件默认负责页面装配、布局组织、边界兜底。
|
||||
- 页面可持有少量与页面展示强绑定的状态。
|
||||
- 当页面开始同时承担以下两项及以上时,应拆出页面级编排模块:
|
||||
- 多个接口请求
|
||||
- 轮询或定时器
|
||||
- 上传/下载流程
|
||||
- 多个 Drawer / Modal / Sheet 子流程
|
||||
- 复杂权限判断
|
||||
- 大量数据清洗与派生
|
||||
|
||||
### 4.2 视图组件默认无副作用
|
||||
|
||||
- 纯视图组件只接收整理好的 props。
|
||||
- 纯视图组件默认不直接请求接口、不直接碰浏览器存储、不直接起轮询。
|
||||
- 如果一个组件必须自带数据流程,它应被明确视为“功能组件”或“场景组件”,而不是伪装成通用组件。
|
||||
|
||||
### 4.3 页面级业务流程应集中编排
|
||||
|
||||
- 页面相关的请求、轮询、草稿保存、上传进度、权限行为、状态联动,应尽量集中在页面编排层。
|
||||
- 页面编排层可以是 hook、store、controller、presenter 或 view-model,不限定技术名词。
|
||||
- 不要求为了形式而一律抽 hook;只有在页面入口已经承担过多流程时才拆。
|
||||
|
||||
### 4.4 前端基础设施应收口
|
||||
|
||||
- API client
|
||||
- 本地存储
|
||||
- Session / token 持久化
|
||||
- 浏览器标题、副作用事件、定时器策略
|
||||
- 配置读取
|
||||
|
||||
以上能力应收口在可识别模块中,不应被页面随机复制。
|
||||
|
||||
### 4.5 复用原则
|
||||
|
||||
- 提炼稳定复用模式,不提炼偶然重复。
|
||||
- 三处以上重复,优先评估抽取。
|
||||
- 如果抽取后的接口比原地代码更难理解,不应抽取。
|
||||
- 不允许制造“只有一个页面使用、但包装层很多”的伪复用。
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端通用规范
|
||||
|
||||
本节适用于 HTTP 服务、RPC 服务、任务处理器和后台作业,不绑定 FastAPI / Spring / NestJS / Gin 等框架。
|
||||
|
||||
### 5.1 启动入口必须只做装配
|
||||
|
||||
- `main`
|
||||
- app factory
|
||||
- bootstrap
|
||||
- container
|
||||
|
||||
这些入口只负责:
|
||||
|
||||
- 创建应用实例
|
||||
- 注册路由/处理器
|
||||
- 初始化中间件
|
||||
- 装配依赖
|
||||
- 生命周期绑定
|
||||
|
||||
不应承担:
|
||||
|
||||
- 业务规则
|
||||
- SQL 或 ORM 编排
|
||||
- 文件系统细节
|
||||
- 长流程任务控制
|
||||
|
||||
### 5.2 Router / Controller / Handler 只做协议转换
|
||||
|
||||
允许:
|
||||
|
||||
- 接收请求参数
|
||||
- 基础校验
|
||||
- 调用用例层
|
||||
- 将领域错误映射为接口错误
|
||||
|
||||
不允许:
|
||||
|
||||
- 在 handler 内直接堆大量 SQL
|
||||
- 一边处理权限,一边处理事务,一边操作文件,一边组装响应模型
|
||||
- 在 handler 内实现长流程状态机
|
||||
|
||||
### 5.3 Service / Use Case 以业务域组织
|
||||
|
||||
- 一个 service 文件只负责一个业务域或一个稳定子主题。
|
||||
- 同域内的查询、写入、校验、少量派生逻辑可以在一起。
|
||||
- 如果一个 service 同时承担多个主题,应优先拆主题而不是拆技术动作。
|
||||
|
||||
### 5.4 数据访问与外部适配要收口
|
||||
|
||||
- 数据库查询
|
||||
- ORM 组装
|
||||
- 缓存细节
|
||||
- 第三方 HTTP 调用
|
||||
- 文件上传下载
|
||||
- 对象存储
|
||||
- 队列/任务系统
|
||||
|
||||
这些细节应尽量沉到 repository / gateway / adapter / infra 中。
|
||||
|
||||
### 5.5 Schema / DTO / Contract 必须纯净
|
||||
|
||||
- DTO 只用于定义契约,不应携带数据库、文件系统、网络调用或业务副作用。
|
||||
- 契约字段演进必须可追踪。
|
||||
- 避免让数据库模型、接口模型、领域模型长期混成一种结构。
|
||||
|
||||
---
|
||||
|
||||
## 6. CLI / 脚本 / Worker 规范
|
||||
|
||||
- 命令入口只负责解析参数、准备依赖、调用用例。
|
||||
- 脚本如果会长期保留,必须从“一次性脚本”升级为可读的结构化模块。
|
||||
- Worker handler 只负责接收消息、提取 payload、调用业务流程、回写状态。
|
||||
- 重试、幂等、死信、超时策略等运行时策略应有单独归属,不应散落在业务逻辑内部。
|
||||
|
||||
---
|
||||
|
||||
## 7. 拆分与合并准则
|
||||
|
||||
### 7.1 何时应拆分
|
||||
|
||||
满足任一项即可考虑拆分:
|
||||
|
||||
- 同一模块出现多个业务主题
|
||||
- 同一模块同时依赖多种外部系统
|
||||
- 同一模块同时承担输入解析、业务编排、持久化和展示
|
||||
- 多人修改时经常产生冲突
|
||||
- 阅读一个改动需要频繁跨越无关上下文
|
||||
- 相同规则被复制到多个入口
|
||||
|
||||
### 7.2 何时不应拆分
|
||||
|
||||
- 仍是单一主题
|
||||
- 代码虽长但顺序可读
|
||||
- 继续拆只会制造纯转发层
|
||||
- 继续拆会让调用链更深、定位更慢
|
||||
- 抽出后接口语义比原代码更含糊
|
||||
|
||||
### 7.3 合并也是一种优化
|
||||
|
||||
- 如果多个模块只是在相互转发、没有独立语义,应考虑合并。
|
||||
- 如果拆分之后需要同时打开 4 到 6 个文件才能理解一个简单流程,通常已经过度拆分。
|
||||
|
||||
---
|
||||
|
||||
## 8. 命名与依赖规则
|
||||
|
||||
### 8.1 命名规则
|
||||
|
||||
- 名称应表达业务主题或技术边界,不表达情绪和历史包袱。
|
||||
- 优先使用“对象 + 语义”命名,而不是“抽象 + 序号”命名。
|
||||
- 避免模糊后缀:
|
||||
- `handler2`
|
||||
- `newService`
|
||||
- `commonUtils`
|
||||
- `tempPage`
|
||||
|
||||
### 8.2 依赖规则
|
||||
|
||||
- 上层可以依赖下层抽象,不应依赖下层杂乱细节。
|
||||
- 共享层不能反向依赖业务层。
|
||||
- 领域模块之间若需协作,应通过明确用例、接口或边界对象完成,而不是互相穿透内部实现。
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试与验证要求
|
||||
|
||||
### 9.1 结构改动后的默认验证
|
||||
|
||||
- 前端结构改动后,至少执行构建或类型校验。
|
||||
- 后端结构改动后,至少执行语法校验、启动校验或最小测试集。
|
||||
- 如果改动涉及契约、权限、状态流转、持久化边界,应追加针对性验证。
|
||||
|
||||
### 9.2 测试优先级
|
||||
|
||||
- 先保关键业务路径
|
||||
- 再保跨层边界
|
||||
- 再保复杂状态流转
|
||||
- 最后补充纯工具覆盖
|
||||
|
||||
### 9.3 文档同步要求
|
||||
|
||||
以下情况必须同步设计文档或架构说明:
|
||||
|
||||
- 新增一层明确职责边界
|
||||
- 新增一个稳定领域模块模板
|
||||
- 改变入口层、用例层、基础设施层的责任划分
|
||||
- 引入新的运行时或新的跨项目复用规范
|
||||
|
||||
---
|
||||
|
||||
## 10. 评审与审计清单
|
||||
|
||||
做结构评审时,默认检查以下问题:
|
||||
|
||||
### 10.1 入口层
|
||||
|
||||
- 入口是否足够薄
|
||||
- 是否混入业务规则
|
||||
- 是否混入外部系统细节
|
||||
|
||||
### 10.2 业务编排层
|
||||
|
||||
- 是否以业务主题组织
|
||||
- 是否承担了过多无关流程
|
||||
- 是否存在纯转发服务
|
||||
|
||||
### 10.3 领域规则层
|
||||
|
||||
- 是否仍保持纯净
|
||||
- 是否被框架、HTTP、数据库协议污染
|
||||
|
||||
### 10.4 基础设施层
|
||||
|
||||
- 副作用是否收口
|
||||
- 是否把业务规则偷偷塞回 adapter / utils / core
|
||||
|
||||
### 10.5 共享层
|
||||
|
||||
- 是否真的跨域复用
|
||||
- 是否把业务逻辑伪装成 common / shared / utils
|
||||
|
||||
### 10.6 演进风险
|
||||
|
||||
- 新增功能是否沿着既有边界落位
|
||||
- 兼容层是否在持续变厚
|
||||
- 是否出现单文件多职责继续膨胀
|
||||
|
||||
---
|
||||
|
||||
## 11. 禁止事项
|
||||
|
||||
- 禁止为了图省事把新逻辑堆回入口层
|
||||
- 禁止为了“文件更短”制造无语义的纯包装层
|
||||
- 禁止在 `utils` / `common` / `shared` 中隐藏领域逻辑
|
||||
- 禁止让页面、路由、handler 直接承载大量持久化或文件系统细节
|
||||
- 禁止把 schema / DTO / config 当成业务逻辑容器
|
||||
- 禁止一次改动里同时重写结构、协议、UI 和业务行为,且没有明确验证策略
|
||||
|
||||
---
|
||||
|
||||
## 12. 执行基线
|
||||
|
||||
后续所有新增功能、重构与代码审计,均以本文档为默认判断依据:
|
||||
|
||||
- 先判断职责边界是否正确
|
||||
- 再判断依赖方向是否健康
|
||||
- 再判断是否需要拆分或合并
|
||||
- 最后才考虑目录美观、文件长短和风格一致性
|
||||
|
||||
当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。
|
||||
|
|
@ -52,7 +52,7 @@ services:
|
|||
--maxmemory 2gb
|
||||
--maxmemory-policy allkeys-lru
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD:-${REDIS_PASSWORD:-Unis@123}}\" ping | grep PONG"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
|
@ -93,9 +93,6 @@ services:
|
|||
API_HOST: 0.0.0.0
|
||||
API_PORT: 8000
|
||||
BASE_URL: ${BASE_URL:-http://localhost}
|
||||
|
||||
# LLM配置
|
||||
QWEN_API_KEY: ${QWEN_API_KEY}
|
||||
# 后端不直接暴露端口,通过nginx代理访问
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
|
|
|
|||
|
|
@ -1,292 +0,0 @@
|
|||
# 客户端下载管理模块
|
||||
|
||||
## 概述
|
||||
|
||||
本模块实现了一个完整的客户端下载管理系统,支持移动端(iOS/Android)和桌面端(Windows/Mac/Linux)的版本管理和下载。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 管理员功能
|
||||
- ✅ 创建/编辑/删除客户端版本
|
||||
- ✅ 管理版本号和版本代码
|
||||
- ✅ 设置最新版本标记
|
||||
- ✅ 启用/禁用特定版本
|
||||
- ✅ 添加更新说明和系统要求
|
||||
- ✅ 平台分类管理(移动端/桌面端)
|
||||
- ✅ 搜索和过滤功能
|
||||
|
||||
### 2. 用户功能
|
||||
- ✅ 查看所有平台的最新客户端
|
||||
- ✅ 按平台分类展示
|
||||
- ✅ 显示版本信息和文件大小
|
||||
- ✅ 一键下载
|
||||
|
||||
### 3. API功能
|
||||
- ✅ 获取所有客户端列表(支持分页和过滤)
|
||||
- ✅ 获取最新版本客户端
|
||||
- ✅ 按平台获取最新版本(用于客户端版本检查)
|
||||
- ✅ CRUD操作(仅管理员)
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 表结构: `client_downloads`
|
||||
|
||||
```sql
|
||||
CREATE TABLE client_downloads (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
platform_type ENUM('mobile', 'desktop') NOT NULL,
|
||||
platform_name VARCHAR(50) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL,
|
||||
version_code INT NOT NULL DEFAULT 1,
|
||||
download_url TEXT NOT NULL,
|
||||
file_size BIGINT,
|
||||
release_notes TEXT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_latest BOOLEAN DEFAULT FALSE,
|
||||
min_system_version VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_by INT,
|
||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
INDEX idx_platform (platform_type, platform_name),
|
||||
INDEX idx_version (version_code),
|
||||
INDEX idx_active (is_active, is_latest)
|
||||
);
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
- `platform_type`: 平台类型(mobile/desktop)
|
||||
- `platform_name`: 具体平台(ios/android/windows/mac_intel/mac_m/linux)
|
||||
- `version`: 版本号字符串(如: 1.0.0)
|
||||
- `version_code`: 版本代码数字(用于版本比较,如: 1000)
|
||||
- `download_url`: 下载链接
|
||||
- `file_size`: 文件大小(字节)
|
||||
- `release_notes`: 更新说明
|
||||
- `is_active`: 是否启用
|
||||
- `is_latest`: 是否为最新版本
|
||||
- `min_system_version`: 最低系统版本要求
|
||||
|
||||
## API接口
|
||||
|
||||
### 公开接口(无需认证)
|
||||
|
||||
#### 1. 获取客户端列表
|
||||
```
|
||||
GET /api/clients/downloads
|
||||
Query参数:
|
||||
- platform_type: mobile|desktop (可选)
|
||||
- platform_name: ios|android|windows|mac_intel|mac_m|linux (可选)
|
||||
- is_active: true|false (可选)
|
||||
- page: 页码 (默认1)
|
||||
- size: 每页数量 (默认50)
|
||||
```
|
||||
|
||||
#### 2. 获取所有最新版本
|
||||
```
|
||||
GET /api/clients/downloads/latest
|
||||
返回: { mobile: [...], desktop: [...] }
|
||||
```
|
||||
|
||||
#### 3. 获取指定平台最新版本
|
||||
```
|
||||
GET /api/clients/downloads/{platform_name}/latest
|
||||
示例: GET /api/clients/downloads/ios/latest
|
||||
```
|
||||
|
||||
#### 4. 获取指定ID的客户端
|
||||
```
|
||||
GET /api/clients/downloads/{id}
|
||||
```
|
||||
|
||||
### 管理员接口(需要管理员权限)
|
||||
|
||||
#### 5. 创建客户端版本
|
||||
```
|
||||
POST /api/clients/downloads
|
||||
Body: {
|
||||
"platform_type": "mobile",
|
||||
"platform_name": "ios",
|
||||
"version": "1.0.0",
|
||||
"version_code": 1000,
|
||||
"download_url": "https://...",
|
||||
"file_size": 52428800,
|
||||
"release_notes": "初始版本...",
|
||||
"is_active": true,
|
||||
"is_latest": false,
|
||||
"min_system_version": "iOS 13.0"
|
||||
}
|
||||
```
|
||||
|
||||
#### 6. 更新客户端版本
|
||||
```
|
||||
PUT /api/clients/downloads/{id}
|
||||
Body: {
|
||||
"version": "1.0.1",
|
||||
"version_code": 1001,
|
||||
"is_latest": true,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
#### 7. 删除客户端版本
|
||||
```
|
||||
DELETE /api/clients/downloads/{id}
|
||||
```
|
||||
|
||||
## 前端组件
|
||||
|
||||
### 1. ClientManagement (管理员页面)
|
||||
位置: `frontend/src/pages/ClientManagement.jsx`
|
||||
|
||||
功能:
|
||||
- 客户端版本CRUD操作
|
||||
- 平台筛选和搜索
|
||||
- 版本状态管理
|
||||
- 响应式卡片布局
|
||||
|
||||
### 2. ClientDownloads (用户下载组件)
|
||||
位置: `frontend/src/components/ClientDownloads.jsx`
|
||||
|
||||
功能:
|
||||
- 展示最新客户端版本
|
||||
- 按平台分类
|
||||
- 一键下载
|
||||
- 可嵌入任何页面
|
||||
|
||||
## 安装和部署
|
||||
|
||||
### 1. 数据库初始化
|
||||
|
||||
```bash
|
||||
# 执行SQL文件
|
||||
mysql -u your_user -p your_database < backend/sql/client_downloads.sql
|
||||
```
|
||||
|
||||
### 2. 后端启动
|
||||
|
||||
后端会自动加载新的路由,无需额外配置。
|
||||
|
||||
### 3. 前端使用
|
||||
|
||||
#### 在管理员页面添加客户端管理:
|
||||
|
||||
```jsx
|
||||
import ClientManagement from './pages/ClientManagement';
|
||||
|
||||
// 在管理员路由中添加
|
||||
<Route path="/admin/clients" element={<ClientManagement user={user} />} />
|
||||
```
|
||||
|
||||
#### 在首页添加下载组件:
|
||||
|
||||
```jsx
|
||||
import ClientDownloads from './components/ClientDownloads';
|
||||
|
||||
// 在Dashboard或任何页面中添加
|
||||
<ClientDownloads />
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 管理员创建新版本
|
||||
|
||||
1. 访问管理后台的"客户端管理"页面
|
||||
2. 点击"新增客户端"按钮
|
||||
3. 填写版本信息:
|
||||
- 选择平台类型和具体平台
|
||||
- 输入版本号和版本代码
|
||||
- 填写下载链接
|
||||
- 添加更新说明
|
||||
- 勾选"设为最新版本"(如果是最新)
|
||||
4. 点击"创建"保存
|
||||
|
||||
### 用户下载客户端
|
||||
|
||||
1. 在首页查看"下载客户端"区域
|
||||
2. 选择对应平台
|
||||
3. 点击卡片即可下载
|
||||
|
||||
### 客户端版本检查
|
||||
|
||||
客户端可以调用API检查是否有新版本:
|
||||
|
||||
```javascript
|
||||
// 检查iOS最新版本
|
||||
const response = await fetch('/api/clients/downloads/ios/latest');
|
||||
const latest = await response.json();
|
||||
|
||||
if (latest.data.version_code > currentVersionCode) {
|
||||
// 提示用户更新
|
||||
showUpdateDialog(latest.data);
|
||||
}
|
||||
```
|
||||
|
||||
## 版本号规范
|
||||
|
||||
- **版本号**: 采用语义化版本(Semantic Versioning),格式: `主版本.次版本.修订号`
|
||||
- 例如: 1.0.0, 1.2.3, 2.0.0
|
||||
|
||||
- **版本代码**: 纯数字,用于程序化比较
|
||||
- 推荐格式: 主版本(2位) + 次版本(2位) + 修订号(2位)
|
||||
- 例如: 1.0.0 -> 10000, 1.2.3 -> 10203, 2.0.0 -> 20000
|
||||
|
||||
## 平台支持
|
||||
|
||||
### 移动端
|
||||
- **iOS**: App Store链接或企业分发链接
|
||||
- **Android**: Google Play链接或APK直接下载
|
||||
|
||||
### 桌面端
|
||||
- **Windows**: .exe安装包
|
||||
- **Mac (Intel)**: Intel架构的.dmg安装包
|
||||
- **Mac (M系列)**: Apple Silicon原生支持的.dmg安装包
|
||||
- **Linux**: .AppImage或.deb/.rpm包
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **下载链接**: 确保下载链接长期有效,建议使用CDN或对象存储
|
||||
2. **文件大小**: 建议填写准确的文件大小,方便用户了解下载时间
|
||||
3. **版本管理**:
|
||||
- 同一平台只能有一个"最新版本"
|
||||
- 设置新版本为最新时,会自动将旧版本的最新标记<E6A087><E8AEB0>除
|
||||
4. **权限控制**: CRUD操作仅管理员可执行,查询接口公开访问
|
||||
|
||||
## 扩展建议
|
||||
|
||||
1. **统计功能**: 添加下载次数统计
|
||||
2. **发布计划**: 支持定时发布新版本
|
||||
3. **灰度发布**: 按百分比或用户组逐步推送新版本
|
||||
4. **更新检查**: 提供SDK方便客户端集成版本检查
|
||||
5. **更新强制**: 支持强制更新标记
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 后端
|
||||
- `backend/sql/client_downloads.sql` - 数据库表结构和初始数据
|
||||
- `backend/app/models/models.py` - Pydantic数据模型
|
||||
- `backend/app/api/endpoints/client_downloads.py` - API路由和业务逻辑
|
||||
- `backend/main.py` - 路由注册
|
||||
|
||||
### 前端
|
||||
- `frontend/src/pages/ClientManagement.jsx` - 管理员管理页面
|
||||
- `frontend/src/pages/ClientManagement.css` - 管理页面样式
|
||||
- `frontend/src/components/ClientDownloads.jsx` - 用户下载组件
|
||||
- `frontend/src/components/ClientDownloads.css` - 下载组件样式
|
||||
- `frontend/src/config/api.js` - API配置
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- FastAPI
|
||||
- MySQL
|
||||
- Pydantic
|
||||
|
||||
### 前端
|
||||
- React
|
||||
- Lucide React Icons
|
||||
- CSS3
|
||||
|
||||
## 作者
|
||||
|
||||
Generated with Claude Code
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>iMeeting - 智能会议助手</title>
|
||||
<title>智听云平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,64 @@ server {
|
|||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# MCP Streamable HTTP 代理
|
||||
# 对外以 /mcp 作为标准入口,避免 MCP Client 处理尾斜杠重定向。
|
||||
location = /mcp {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
chunked_transfer_encoding on;
|
||||
}
|
||||
|
||||
location = /mcp/ {
|
||||
return 308 /mcp;
|
||||
}
|
||||
|
||||
location = /docs {
|
||||
proxy_pass http://backend:8000/docs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /redoc {
|
||||
proxy_pass http://backend:8000/redoc;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /openapi.json {
|
||||
proxy_pass http://backend:8000/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /docs/oauth2-redirect {
|
||||
proxy_pass http://backend:8000/docs/oauth2-redirect;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 上传文件代理 (使用 ^~ 提高优先级,避免被静态文件正则匹配拦截)
|
||||
location ^~ /uploads/ {
|
||||
proxy_pass http://backend:8000;
|
||||
|
|
@ -57,4 +115,4 @@ server {
|
|||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.6",
|
||||
|
|
@ -19,8 +20,7 @@
|
|||
"axios": "^1.6.2",
|
||||
"canvg": "^4.0.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.2",
|
||||
"lucide-react": "^0.294.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"markmap-common": "^0.18.9",
|
||||
"markmap-lib": "^0.18.12",
|
||||
"markmap-view": "^0.18.12",
|
||||
|
|
|
|||
|
|
@ -7,12 +7,10 @@
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: 'MiSans', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f8fafc;
|
||||
background-color: transparent;
|
||||
color: #1e293b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
|
@ -22,6 +20,347 @@ body {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
transition: box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, color 0.22s ease;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.ant-btn:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ant-btn:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ant-btn .anticon {
|
||||
font-size: 0.98em;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-default,
|
||||
.ant-btn.ant-btn-dashed {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
color: #294261;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-default:hover,
|
||||
.ant-btn.ant-btn-dashed:hover {
|
||||
background: #ffffff;
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-primary {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 45%, #1e40af 100%);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-primary:hover {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 48%, #1d4ed8 100%);
|
||||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-primary.ant-btn-dangerous,
|
||||
.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text) {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 48%, #b91c1c 100%);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-primary.ant-btn-dangerous:hover,
|
||||
.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text):hover {
|
||||
box-shadow: 0 10px 20px rgba(220, 38, 38, 0.18);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link,
|
||||
.ant-btn.ant-btn-text {
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link {
|
||||
padding-inline: 6px;
|
||||
color: #31568b;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link:hover {
|
||||
color: #1d4ed8;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.ant-btn-dangerous,
|
||||
.ant-btn.ant-btn-text.ant-btn-dangerous {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.ant-btn-dangerous:hover,
|
||||
.ant-btn.ant-btn-text.ant-btn-dangerous:hover {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-blue,
|
||||
.ant-btn.ant-btn-primary.btn-soft-blue {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%);
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 10px 22px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-blue:hover,
|
||||
.ant-btn.ant-btn-primary.btn-soft-blue:hover {
|
||||
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-color: #93c5fd;
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 10px 20px rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-violet,
|
||||
.ant-btn.ant-btn-primary.btn-soft-violet {
|
||||
background: linear-gradient(180deg, #faf5ff 0%, #f3e8ff 100%);
|
||||
border-color: #d8b4fe;
|
||||
color: #7c3aed;
|
||||
box-shadow: 0 10px 22px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-violet:hover,
|
||||
.ant-btn.ant-btn-primary.btn-soft-violet:hover {
|
||||
background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 100%);
|
||||
border-color: #c084fc;
|
||||
color: #6d28d9;
|
||||
box-shadow: 0 10px 20px rgba(124, 58, 237, 0.14);
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-green,
|
||||
.ant-btn.ant-btn-primary.btn-soft-green {
|
||||
background: linear-gradient(180deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
border-color: #86efac;
|
||||
color: #15803d;
|
||||
box-shadow: 0 10px 22px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-green:hover,
|
||||
.ant-btn.ant-btn-primary.btn-soft-green:hover {
|
||||
background: linear-gradient(180deg, #dcfce7 0%, #bbf7d0 100%);
|
||||
border-color: #4ade80;
|
||||
color: #166534;
|
||||
box-shadow: 0 10px 20px rgba(34, 197, 94, 0.14);
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-red,
|
||||
.ant-btn.ant-btn-primary.btn-soft-red {
|
||||
background: linear-gradient(180deg, #fff7f7 0%, #fff1f2 100%);
|
||||
border-color: #fecdd3;
|
||||
color: #dc2626;
|
||||
box-shadow: 0 10px 22px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.ant-btn.btn-soft-red:hover,
|
||||
.ant-btn.ant-btn-primary.btn-soft-red:hover {
|
||||
background: linear-gradient(180deg, #fff1f2 0%, #ffe4e6 100%);
|
||||
border-color: #fda4af;
|
||||
color: #b91c1c;
|
||||
box-shadow: 0 10px 20px rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.ant-btn.btn-pill-primary {
|
||||
height: 40px;
|
||||
padding-inline: 16px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 10px 18px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-lg.btn-pill-primary {
|
||||
height: 44px;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
|
||||
.ant-btn.btn-pill-secondary {
|
||||
height: 40px;
|
||||
padding-inline: 16px;
|
||||
border-radius: 999px;
|
||||
border-color: #dbe3ef;
|
||||
background: #fff;
|
||||
color: #49627f;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ant-btn.btn-pill-secondary:hover,
|
||||
.ant-btn.btn-pill-secondary:focus {
|
||||
border-color: #c7d4e4;
|
||||
background: #f8fafc;
|
||||
color: #355171;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-lg.btn-pill-secondary {
|
||||
height: 44px;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
|
||||
.ant-card-hoverable {
|
||||
transition: box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.ant-card-hoverable:hover {
|
||||
transform: none !important;
|
||||
box-shadow: 0 12px 24px rgba(31, 78, 146, 0.12);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-view,
|
||||
.ant-btn.ant-btn-text.btn-text-view,
|
||||
.ant-btn.ant-btn-link.btn-text-neutral,
|
||||
.ant-btn.ant-btn-text.btn-text-neutral,
|
||||
.ant-btn.ant-btn-link.btn-text-edit,
|
||||
.ant-btn.ant-btn-text.btn-text-edit,
|
||||
.ant-btn.ant-btn-link.btn-text-accent,
|
||||
.ant-btn.ant-btn-text.btn-text-accent,
|
||||
.ant-btn.ant-btn-link.btn-text-delete,
|
||||
.ant-btn.ant-btn-text.btn-text-delete,
|
||||
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete,
|
||||
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete {
|
||||
border: 1px solid #d7e4f6;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88), 0 2px 8px rgba(25, 63, 121, 0.06);
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-view,
|
||||
.ant-btn.ant-btn-text.btn-text-view {
|
||||
color: #2563eb;
|
||||
border-color: #c9ddfb;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-view:hover,
|
||||
.ant-btn.ant-btn-text.btn-text-view:hover {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
|
||||
border-color: #a9c9fa;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-neutral,
|
||||
.ant-btn.ant-btn-text.btn-text-neutral {
|
||||
color: #5f7392;
|
||||
border-color: #d7e4f6;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-neutral:hover,
|
||||
.ant-btn.ant-btn-text.btn-text-neutral:hover {
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f1f6fc 100%);
|
||||
border-color: #bfd0e6;
|
||||
color: #355171;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-edit,
|
||||
.ant-btn.ant-btn-text.btn-text-edit {
|
||||
color: #1d4ed8;
|
||||
border-color: #c9ddfb;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-edit:hover,
|
||||
.ant-btn.ant-btn-text.btn-text-edit:hover {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
|
||||
border-color: #a9c9fa;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-accent,
|
||||
.ant-btn.ant-btn-text.btn-text-accent {
|
||||
color: #7c3aed;
|
||||
border-color: #dbc8fb;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-accent:hover,
|
||||
.ant-btn.ant-btn-text.btn-text-accent:hover {
|
||||
background: linear-gradient(180deg, #fbf8ff 0%, #f3ebff 100%);
|
||||
border-color: #caa6fb;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-delete,
|
||||
.ant-btn.ant-btn-text.btn-text-delete,
|
||||
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete,
|
||||
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete {
|
||||
color: #dc2626;
|
||||
border-color: #fecdd3;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-link.btn-text-delete:hover,
|
||||
.ant-btn.ant-btn-text.btn-text-delete:hover,
|
||||
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete:hover,
|
||||
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete:hover {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fff5f5 100%);
|
||||
border-color: #fda4af;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.ant-btn.ant-btn-icon-only.ant-btn-text,
|
||||
.ant-btn.ant-btn-icon-only.ant-btn-link {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.ant-btn-icon-only {
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.ant-btn.btn-action-text-lg {
|
||||
min-height: 32px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ant-btn.btn-action-text-sm,
|
||||
.ant-btn.btn-action-inline {
|
||||
min-height: 26px;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ant-btn.btn-action-icon-lg,
|
||||
.ant-btn.btn-action-icon-sm,
|
||||
.ant-btn.btn-action-compact {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ant-btn.btn-action-icon-lg.ant-btn-icon-only {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding-inline: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ant-btn.btn-action-icon-sm.ant-btn-icon-only,
|
||||
.ant-btn.btn-action-compact.ant-btn-icon-only {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding-inline: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.ant-btn.btn-action-icon-lg .anticon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ant-btn.btn-action-icon-sm .anticon,
|
||||
.ant-btn.btn-action-compact .anticon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -83,7 +422,7 @@ body {
|
|||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
|
@ -147,4 +486,4 @@ body {
|
|||
.text-gray-500 { color: #64748b; }
|
||||
.text-gray-600 { color: #475569; }
|
||||
.text-gray-700 { color: #334155; }
|
||||
.text-gray-900 { color: #0f172a; }
|
||||
.text-gray-900 { color: #0f172a; }
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue