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 初始化参数(Docker 内置 MySQL)。
|
||||||
MYSQL_ROOT_PASSWORD=Unis@123
|
# 当后端也运行在 Docker 中时,数据库主机应为服务名 `mysql`。
|
||||||
|
# 使用 `./start-external.sh` 时,请改成外部 MySQL 地址或服务名。
|
||||||
|
MYSQL_HOST=mysql
|
||||||
|
MYSQL_ROOT_PASSWORD=change_this_password
|
||||||
MYSQL_DATABASE=imeeting
|
MYSQL_DATABASE=imeeting
|
||||||
MYSQL_USER=imeeting
|
MYSQL_USER=imeeting
|
||||||
MYSQL_PASSWORD=Unis@123
|
MYSQL_PASSWORD=change_this_password
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
|
|
||||||
# ==================== 缓存配置 ====================
|
# ==================== 缓存配置 ====================
|
||||||
# Redis配置
|
# Redis 初始化参数(Docker 内置 Redis)。
|
||||||
|
# 当后端也运行在 Docker 中时,Redis 主机应为服务名 `redis`。
|
||||||
|
# 使用 `./start-external.sh` 时,请改成外部 Redis 地址或服务名。
|
||||||
|
REDIS_HOST=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=Unis@123
|
REDIS_PASSWORD=change_this_password
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
# ==================== 应用端口配置 ====================
|
# ==================== 应用端口配置 ====================
|
||||||
|
|
@ -17,14 +30,11 @@ REDIS_DB=0
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
|
|
||||||
# ==================== 应用配置 ====================
|
# ==================== 应用配置 ====================
|
||||||
# 应用访问地址(用于生成外部链接、二维码等)
|
# 应用访问地址(用于生成外部链接、客户端下载链接,以及音频转录时提供给云端拉取音频文件的公网 URL)
|
||||||
# - 直接访问: http://服务器IP
|
# - 本地联调可先填写: http://localhost
|
||||||
# - 域名访问: https://your-domain.com (需配置接入服务器Nginx)
|
# - 使用云端音频转录时,必须改成外部可访问的域名或公网地址,不能填写容器名、127.0.0.1 或内网地址
|
||||||
BASE_URL=https://imeeting.unisspace.com
|
# - 不要以 / 结尾,例如: https://your-domain.com
|
||||||
|
BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# 前端API地址(通过Nginx代理访问后端)
|
# 前端API地址(通过Nginx代理访问后端)
|
||||||
VITE_API_BASE_URL=/api
|
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
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors and local tooling
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.claude/
|
||||||
|
.gemini-clipboard/
|
||||||
|
.memsearch/
|
||||||
|
frontend/.memsearch/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
backend/.env
|
backend/.env
|
||||||
frontend/.env
|
frontend/.env
|
||||||
frontend/.env.local
|
frontend/.env.local
|
||||||
|
frontend/.env.development.local
|
||||||
|
frontend/.env.test.local
|
||||||
frontend/.env.production.local
|
frontend/.env.production.local
|
||||||
|
|
||||||
# Docker / Data
|
# Project data and local-only assets
|
||||||
data/
|
data/
|
||||||
backups/
|
backups/
|
||||||
logs_export/
|
logs_export/
|
||||||
|
backend/uploads/
|
||||||
# Node.js
|
backend/logs/
|
||||||
node_modules/
|
backend/venv/
|
||||||
frontend/node_modules/
|
backend/test/
|
||||||
npm-debug.log*
|
# Only keep the latest full-deploy SQL entrypoints in repo
|
||||||
yarn-debug.log*
|
backend/sql/*
|
||||||
yarn-error.log*
|
!backend/sql/imeeting-schema-latest.sql
|
||||||
frontend/dist/
|
!backend/sql/imeeting-seed-latest.sql
|
||||||
frontend/logs/
|
backend/sql/migrations/
|
||||||
|
backend/scripts/
|
||||||
|
资料/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
@ -29,13 +43,9 @@ __pycache__/
|
||||||
*$py.class
|
*$py.class
|
||||||
*.so
|
*.so
|
||||||
.Python
|
.Python
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
.env/
|
|
||||||
.venv/
|
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
|
||||||
dist/
|
dist/
|
||||||
|
develop-eggs/
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
|
@ -43,21 +53,104 @@ lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
|
share/python-wheels/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
backend/venv/
|
MANIFEST
|
||||||
backend/__pycache__/
|
htmlcov/
|
||||||
backend/logs/
|
.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
|
# Node.js / frontend
|
||||||
backend/uploads/
|
node_modules/
|
||||||
资料/
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
# IDEs
|
frontend/logs/
|
||||||
.vscode/
|
jspm_packages/
|
||||||
.idea/
|
web_modules/
|
||||||
*.swp
|
bower_components/
|
||||||
*.swo
|
.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` ⭐ | 一键启动脚本(推荐) |
|
| `start.sh` ⭐ | 一键启动脚本(推荐) |
|
||||||
| `stop.sh` | 停止服务脚本 |
|
| `stop.sh` | 停止服务脚本 |
|
||||||
| `manage.sh` | 服务管理脚本 |
|
| `manage.sh` | 服务管理脚本 |
|
||||||
| `generate-ssl-cert.sh` | SSL证书生成脚本 |
|
|
||||||
| `DOCKER_DEPLOYMENT.md` | 详细部署文档 |
|
|
||||||
| `DOCKER_README.md` | 本文档 |
|
| `DOCKER_README.md` | 本文档 |
|
||||||
|
|
||||||
## 🏗️ 系统架构
|
## 🏗️ 系统架构
|
||||||
|
|
||||||
```
|
```
|
||||||
方式一:直接访问
|
方式一:直接访问
|
||||||
用户 → http://服务器IP → iMeeting Nginx (80) → Frontend/Backend
|
用户 → http://服务器IP → iMeeting Frontend(Web) (80) → Frontend/Backend
|
||||||
|
|
||||||
方式二:域名访问(HTTPS)
|
方式二:域名访问(HTTPS)
|
||||||
用户 → https://domain → 接入服务器Nginx (SSL) → iMeeting服务器 (80) → Frontend/Backend
|
用户 → https://domain → 接入服务器Nginx (SSL) → iMeeting服务器 (80) → Frontend/Backend
|
||||||
```
|
```
|
||||||
|
|
||||||
**5个服务容器**:
|
**4个服务容器**:
|
||||||
- **Nginx**: HTTP入口(仅HTTP,SSL由接入服务器处理)
|
|
||||||
- **Frontend**: React前端应用
|
- **Frontend**: React前端应用
|
||||||
- **Backend**: FastAPI后端服务
|
- **Backend**: FastAPI后端服务
|
||||||
- **MySQL**: 数据库
|
- **MySQL**: 数据库
|
||||||
|
|
@ -43,43 +40,35 @@
|
||||||
```bash
|
```bash
|
||||||
# 1. 复制并配置环境变量
|
# 1. 复制并配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
vim .env # 配置七牛云、LLM密钥等
|
vim .env # 配置 BASE_URL、密码等
|
||||||
|
|
||||||
# 2. 一键启动
|
# 2. 一键启动
|
||||||
./start.sh
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 完整 Docker 一体化部署不会读取 `backend/.env`
|
||||||
|
|
||||||
脚本会自动完成:
|
脚本会自动完成:
|
||||||
- ✅ 检查Docker依赖
|
- ✅ 检查Docker依赖
|
||||||
- ✅ 创建必要目录
|
- ✅ 创建必要目录
|
||||||
- ✅ 生成SSL证书
|
|
||||||
- ✅ 配置后端环境变量
|
|
||||||
- ✅ 构建前端
|
|
||||||
- ✅ 启动所有服务
|
- ✅ 启动所有服务
|
||||||
- ✅ 等待健康检查
|
- ✅ 等待健康检查
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 后端镜像现在依赖系统级 `ffmpeg/ffprobe` 做音频预处理,已在 `backend/Dockerfile` 中安装,无需宿主机额外安装。
|
||||||
|
|
||||||
### 方式二:手动启动
|
### 方式二:手动启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 配置环境变量
|
# 1. 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
vim .env # 配置主环境变量
|
vim .env # 配置主环境变量
|
||||||
vim backend/.env # 配置后端环境变量
|
|
||||||
|
|
||||||
# 2. 生成SSL证书
|
# 2. 启动所有服务
|
||||||
./generate-ssl-cert.sh
|
|
||||||
|
|
||||||
# 3. 构建前端
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# 4. 启动所有服务
|
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# 5. 查看服务状态
|
# 3. 查看服务状态
|
||||||
docker-compose ps
|
docker-compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -94,24 +83,19 @@ docker-compose ps
|
||||||
|
|
||||||
**域名访问(HTTPS)**:
|
**域名访问(HTTPS)**:
|
||||||
- 需要在接入服务器配置Nginx反向代理
|
- 需要在接入服务器配置Nginx反向代理
|
||||||
- 参考文档:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
|
- 参考本文“域名和 HTTPS 配置”章节
|
||||||
- 访问示例:https://imeeting.yourdomain.com
|
- 访问示例:https://imeeting.yourdomain.com
|
||||||
|
|
||||||
## ⚙️ 环境变量配置
|
## ⚙️ 环境变量配置
|
||||||
|
|
||||||
### 必须配置项
|
### 必须配置项
|
||||||
|
|
||||||
编辑 `.env` 文件,修改以下配置:
|
编辑根目录 `.env` 文件,修改以下配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 七牛云存储(必填,否则无法上传文件)
|
# Docker 一体化部署下,后端容器访问内置 MySQL/Redis 使用服务名
|
||||||
QINIU_ACCESS_KEY=your_actual_access_key
|
MYSQL_HOST=mysql
|
||||||
QINIU_SECRET_KEY=your_actual_secret_key
|
REDIS_HOST=redis
|
||||||
QINIU_BUCKET=your_bucket_name
|
|
||||||
QINIU_DOMAIN=your_domain.clouddn.com
|
|
||||||
|
|
||||||
# LLM API(必填,否则无法使用AI功能)
|
|
||||||
QWEN_API_KEY=your_actual_qwen_api_key
|
|
||||||
|
|
||||||
# 生产环境必改密码
|
# 生产环境必改密码
|
||||||
MYSQL_ROOT_PASSWORD=change_this_password
|
MYSQL_ROOT_PASSWORD=change_this_password
|
||||||
|
|
@ -122,18 +106,42 @@ REDIS_PASSWORD=change_this_password
|
||||||
### 可选配置项
|
### 可选配置项
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 应用访问地址(用于生成二维码等)
|
# 应用访问地址(用于生成外链,以及音频转录时给云端拉取音频文件)
|
||||||
|
# 如果使用云端音频转录,必须改成外部可访问的域名或公网地址,不能填写 localhost / 127.0.0.1 / 容器名
|
||||||
BASE_URL=http://localhost # 生产环境改为: https://your-domain.com
|
BASE_URL=http://localhost # 生产环境改为: https://your-domain.com
|
||||||
|
|
||||||
# Nginx端口(默认80/443)
|
# Nginx端口(默认80/443)
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
HTTPS_PORT=443
|
HTTPS_PORT=443
|
||||||
|
|
||||||
# 如需直接访问后端/前端(开发调试用)
|
# 如需调整容器对外端口
|
||||||
# BACKEND_PORT=8001
|
# BACKEND_PORT=8000
|
||||||
# FRONTEND_PORT=3001
|
# 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/` 目录:
|
所有数据存储在 `./data/` 目录:
|
||||||
|
|
@ -146,7 +154,7 @@ data/
|
||||||
└── logs/ # 日志文件
|
└── logs/ # 日志文件
|
||||||
├── backend/
|
├── backend/
|
||||||
├── frontend/
|
├── frontend/
|
||||||
└── nginx/
|
└── frontend/
|
||||||
```
|
```
|
||||||
|
|
||||||
**重要提示**:
|
**重要提示**:
|
||||||
|
|
@ -182,13 +190,13 @@ docker-compose ps
|
||||||
|
|
||||||
# 查看日志
|
# 查看日志
|
||||||
docker-compose logs -f # 所有服务
|
docker-compose logs -f # 所有服务
|
||||||
docker-compose logs -f nginx # Nginx日志
|
docker-compose logs -f frontend # 前端Web日志
|
||||||
docker-compose logs -f backend # 后端日志
|
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 # 重启所有
|
||||||
docker-compose restart nginx # 重启Nginx
|
docker-compose restart frontend # 重启前端
|
||||||
|
|
||||||
# 停止服务
|
# 停止服务
|
||||||
./stop.sh # 交互式停止
|
./stop.sh # 交互式停止
|
||||||
|
|
@ -201,7 +209,7 @@ iMeeting服务器本身仅提供HTTP服务。如需通过域名访问(HTTPS)
|
||||||
|
|
||||||
### 配置接入服务器
|
### 配置接入服务器
|
||||||
|
|
||||||
完整配置步骤请参考:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
|
完整配置步骤请参考本文档中的反向代理章节
|
||||||
|
|
||||||
**简要步骤**:
|
**简要步骤**:
|
||||||
1. 在接入服务器安装Nginx
|
1. 在接入服务器安装Nginx
|
||||||
|
|
@ -225,7 +233,7 @@ server {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
详见:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
|
详见:本文档中的反向代理章节
|
||||||
|
|
||||||
## 🔍 故障排查
|
## 🔍 故障排查
|
||||||
|
|
||||||
|
|
@ -236,7 +244,7 @@ server {
|
||||||
docker-compose logs -f
|
docker-compose logs -f
|
||||||
|
|
||||||
# 文件日志
|
# 文件日志
|
||||||
tail -f data/logs/nginx/imeeting_error.log
|
tail -f data/logs/frontend/*.log
|
||||||
tail -f data/logs/backend/*.log
|
tail -f data/logs/backend/*.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -267,19 +275,19 @@ curl http://localhost/docs
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 端口被占用 | 修改.env中的HTTP_PORT |
|
| 端口被占用 | 修改.env中的HTTP_PORT |
|
||||||
| 502错误 | 检查backend和frontend是否健康 |
|
| 502错误 | 检查backend和frontend是否健康 |
|
||||||
| 数据库连接失败 | 检查backend/.env配置 |
|
| 数据库连接失败 | 检查根目录 `.env` 中的数据库配置;外接中间件模式确认 `MYSQL_HOST` / `DB_HOST` 是否正确 |
|
||||||
| 前端无法访问API | 检查VITE_API_BASE_URL配置 |
|
| 前端无法访问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(Web) | imeeting-frontend | 80 | 80 | ✅ |
|
||||||
| Frontend | imeeting-frontend | 3001 | - | ✅ |
|
| Backend | imeeting-backend | 8000 | 8000 | ✅ |
|
||||||
| Backend | imeeting-backend | 8001 | - | ✅ |
|
|
||||||
| MySQL | imeeting-mysql | 3306 | - | ✅ |
|
| MySQL | imeeting-mysql | 3306 | - | ✅ |
|
||||||
| Redis | imeeting-redis | 6379 | - | ✅ |
|
| Redis | imeeting-redis | 6379 | - | ✅ |
|
||||||
|
|
||||||
|
|
@ -309,7 +317,7 @@ npm run build
|
||||||
cd ..
|
cd ..
|
||||||
docker-compose build frontend
|
docker-compose build frontend
|
||||||
docker-compose up -d 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_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. ✅ **设置文件权限**
|
3. ✅ **设置文件权限**
|
||||||
```bash
|
```bash
|
||||||
chmod 600 .env backend/.env
|
chmod 600 .env
|
||||||
chmod 600 nginx/ssl/server.key
|
确保接入层反向代理证书文件权限正确
|
||||||
```
|
```
|
||||||
|
|
||||||
4. ✅ **启用防火墙**
|
4. ✅ **启用防火墙**
|
||||||
|
|
@ -389,8 +397,8 @@ tar xzf imeeting_backup_20240101.tar.gz
|
||||||
|
|
||||||
详细的部署说明、配置选项、性能优化等,请查看:
|
详细的部署说明、配置选项、性能优化等,请查看:
|
||||||
|
|
||||||
- **详细部署文档**: [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md)
|
- **部署说明**: [DOCKER_README.md](DOCKER_README.md)
|
||||||
- **Nginx配置说明**: [nginx/README.md](nginx/README.md)
|
- **前端代理配置**: [frontend/nginx.conf](frontend/nginx.conf)
|
||||||
|
|
||||||
## 🆘 获取帮助
|
## 🆘 获取帮助
|
||||||
|
|
||||||
|
|
@ -398,7 +406,7 @@ tar xzf imeeting_backup_20240101.tar.gz
|
||||||
|
|
||||||
1. 查看日志:`docker-compose logs -f`
|
1. 查看日志:`docker-compose logs -f`
|
||||||
2. 查看健康状态:`docker-compose ps`
|
2. 查看健康状态:`docker-compose ps`
|
||||||
3. 查看详细文档:`DOCKER_DEPLOYMENT.md`
|
3. 查看详细文档:`DOCKER_README.md`
|
||||||
4. 提交Issue到项目仓库
|
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+
|
- Node.js 22.12+
|
||||||
- Python 3.9+
|
- Python 3.12+
|
||||||
- MySQL 5.7+
|
- MySQL 5.7+
|
||||||
- Redis 5.0+
|
- Redis 5.0+
|
||||||
- Docker (可选)
|
- Docker (可选)
|
||||||
|
|
@ -95,7 +95,7 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python main.py
|
python app/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
默认运行在 `http://localhost:8000`
|
默认运行在 `http://localhost:8000`
|
||||||
|
|
@ -110,6 +110,17 @@ npm run dev
|
||||||
|
|
||||||
默认运行在 `http://localhost:5173`
|
默认运行在 `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/
|
uploads/
|
||||||
|
test/
|
||||||
|
sql/
|
||||||
|
scripts/
|
||||||
|
|
||||||
# 测试和开发文件
|
# 测试和开发文件
|
||||||
test/
|
test/
|
||||||
|
|
@ -38,6 +41,8 @@ htmlcov/
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# 环境变量
|
# 环境变量
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,30 @@
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
# Docker环境使用容器名称
|
# 仅供“宿主机直接运行 backend”使用
|
||||||
DB_HOST=10.100.51.51
|
# 如果 backend 运行在 Docker 中,且数据库也在同一 Docker 网络,主机名应填服务名(如 mysql)
|
||||||
DB_USER=root
|
# 如果是宿主机直接运行 backend,再填写 127.0.0.1 或实际地址
|
||||||
DB_PASSWORD=Unis@123
|
DB_HOST=127.0.0.1
|
||||||
DB_NAME=imeeting_dev
|
DB_USER=imeeting
|
||||||
|
DB_PASSWORD=change_this_password
|
||||||
|
DB_NAME=imeeting
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|
||||||
# ==================== Redis配置 ====================
|
# ==================== Redis配置 ====================
|
||||||
# Docker环境使用容器名称
|
# 如果 backend 运行在 Docker 中,且 Redis 也在同一 Docker 网络,主机名应填服务名(如 redis)
|
||||||
REDIS_HOST=10.100.51.51
|
# 如果是宿主机直接运行 backend,再填写 127.0.0.1 或实际地址
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
REDIS_PASSWORD=Unis@123
|
REDIS_PASSWORD=change_this_password
|
||||||
|
|
||||||
# ==================== API配置 ====================
|
# ==================== API配置 ====================
|
||||||
API_HOST=0.0.0.0
|
API_HOST=0.0.0.0
|
||||||
API_PORT=8001
|
API_PORT=8000
|
||||||
|
|
||||||
# ==================== 应用配置 ====================
|
# ==================== 应用配置 ====================
|
||||||
# 应用访问地址(用于生成外部链接、二维码等)
|
# 直接运行 backend 时可在这里配置 BASE_URL;
|
||||||
# 开发环境: http://localhost
|
# Docker 容器部署时,优先使用仓库根目录 .env 中的 BASE_URL。
|
||||||
# 生产环境: https://your-domain.com
|
# 使用云端音频转录时,必须填写云端可以访问到的公网地址,且不要以 / 结尾。
|
||||||
BASE_URL=http://imeeting.unisspace.com
|
# BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# ==================== LLM配置 ====================
|
|
||||||
# 通义千问API密钥(请替换为实际密钥)
|
|
||||||
QWEN_API_KEY=sk-c2bf06ea56b4491ea3d1e37fdb472b8f
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 转录轮询配置 ====================
|
# ==================== 转录轮询配置 ====================
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
RUN apt-get update && apt-get install -y \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
curl \
|
||||||
|
ffmpeg \
|
||||||
default-libmysqlclient-dev \
|
default-libmysqlclient-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
&& pip install --index-url https://mirrors.aliyun.com/pypi/simple --no-cache-dir -r requirements.txt \
|
&& 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.auth import get_current_admin_user, get_current_user
|
||||||
from app.core.response import create_api_response
|
from app.models.models import (
|
||||||
from app.core.database import get_db_connection
|
CreateMenuRequest,
|
||||||
from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo, UpdateRolePermissionsRequest, RoleInfo
|
CreateRoleRequest,
|
||||||
from typing import List
|
UpdateMenuRequest,
|
||||||
|
UpdateRolePermissionsRequest,
|
||||||
|
UpdateRoleRequest,
|
||||||
|
)
|
||||||
|
import app.services.admin_service as admin_service
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# ========== 菜单权限管理接口 ==========
|
|
||||||
|
|
||||||
@router.get("/admin/menus")
|
@router.get("/admin/menus")
|
||||||
async def get_all_menus(current_user=Depends(get_current_admin_user)):
|
async def get_all_menus(current_user=Depends(get_current_admin_user)):
|
||||||
"""
|
return admin_service.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 menus
|
|
||||||
ORDER BY sort_order ASC, menu_id ASC
|
|
||||||
"""
|
|
||||||
cursor.execute(query)
|
|
||||||
menus = cursor.fetchall()
|
|
||||||
|
|
||||||
menu_list = [MenuInfo(**menu) for menu in menus]
|
|
||||||
|
|
||||||
return create_api_response(
|
@router.post("/admin/menus")
|
||||||
code="200",
|
async def create_menu(request: CreateMenuRequest, current_user=Depends(get_current_admin_user)):
|
||||||
message="获取菜单列表成功",
|
return admin_service.create_menu(request)
|
||||||
data=MenuListResponse(menus=menu_list, total=len(menu_list))
|
|
||||||
)
|
|
||||||
except Exception as e:
|
@router.put("/admin/menus/{menu_id}")
|
||||||
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
|
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")
|
@router.get("/admin/roles")
|
||||||
async def get_all_roles(current_user=Depends(get_current_admin_user)):
|
async def get_all_roles(current_user=Depends(get_current_admin_user)):
|
||||||
"""
|
return admin_service.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 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(
|
@router.post("/admin/roles")
|
||||||
code="200",
|
async def create_role(request: CreateRoleRequest, current_user=Depends(get_current_admin_user)):
|
||||||
message="获取角色列表成功",
|
return admin_service.create_role(request)
|
||||||
data={"roles": roles, "total": len(roles)}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
@router.put("/admin/roles/{role_id}")
|
||||||
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
|
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")
|
@router.get("/admin/roles/{role_id}/permissions")
|
||||||
async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)):
|
async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)):
|
||||||
"""
|
return admin_service.get_role_permissions(role_id)
|
||||||
获取指定角色的菜单权限
|
|
||||||
只有管理员才能访问
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with get_db_connection() as connection:
|
|
||||||
cursor = connection.cursor(dictionary=True)
|
|
||||||
|
|
||||||
# 检查角色是否存在
|
|
||||||
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")
|
@router.put("/admin/roles/{role_id}/permissions")
|
||||||
async def update_role_permissions(
|
async def update_role_permissions(
|
||||||
role_id: int,
|
role_id: int,
|
||||||
request: UpdateRolePermissionsRequest,
|
request: UpdateRolePermissionsRequest,
|
||||||
current_user=Depends(get_current_admin_user)
|
current_user=Depends(get_current_admin_user),
|
||||||
):
|
):
|
||||||
"""
|
return admin_service.update_role_permissions(role_id, request)
|
||||||
更新指定角色的菜单权限
|
|
||||||
只有管理员才能访问
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with get_db_connection() as connection:
|
|
||||||
cursor = connection.cursor(dictionary=True)
|
|
||||||
|
|
||||||
# 检查角色是否存在
|
|
||||||
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")
|
@router.get("/menus/user")
|
||||||
async def get_user_menus(current_user=Depends(get_current_user)):
|
async def get_user_menus(current_user=Depends(get_current_user)):
|
||||||
"""
|
return admin_service.get_user_menus(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)}")
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,248 +1,25 @@
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from app.core.auth import get_current_admin_user
|
from app.core.auth import get_current_admin_user
|
||||||
from app.core.response import create_api_response
|
import app.services.admin_dashboard_service as admin_dashboard_service
|
||||||
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
|
|
||||||
|
|
||||||
router = APIRouter()
|
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")
|
@router.get("/admin/dashboard/stats")
|
||||||
async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
|
async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
|
||||||
"""获取管理员 Dashboard 统计数据"""
|
return await admin_dashboard_service.get_dashboard_stats(current_user)
|
||||||
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)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/online-users")
|
@router.get("/admin/online-users")
|
||||||
async def get_online_users(current_user=Depends(get_current_admin_user)):
|
async def get_online_users(current_user=Depends(get_current_admin_user)):
|
||||||
"""获取在线用户列表"""
|
return await admin_dashboard_service.get_online_users(current_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)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/kick-user/{user_id}")
|
@router.post("/admin/kick-user/{user_id}")
|
||||||
async def kick_user(user_id: int, current_user=Depends(get_current_admin_user)):
|
async def kick_user(user_id: int, current_user=Depends(get_current_admin_user)):
|
||||||
"""踢出用户(撤销该用户的所有 token)"""
|
return await admin_dashboard_service.kick_user(user_id, current_user)
|
||||||
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)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/tasks/monitor")
|
@router.get("/admin/tasks/monitor")
|
||||||
|
|
@ -252,207 +29,19 @@ async def monitor_tasks(
|
||||||
limit: int = Query(20, ge=1, le=100, description="返回数量限制"),
|
limit: int = Query(20, ge=1, le=100, description="返回数量限制"),
|
||||||
current_user=Depends(get_current_admin_user)
|
current_user=Depends(get_current_admin_user)
|
||||||
):
|
):
|
||||||
"""监控任务进度"""
|
return await admin_dashboard_service.monitor_tasks(task_type, status, limit, current_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)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/system/resources")
|
@router.get("/admin/system/resources")
|
||||||
async def get_system_resources(current_user=Depends(get_current_admin_user)):
|
async def get_system_resources(current_user=Depends(get_current_admin_user)):
|
||||||
"""获取服务器资源使用情况"""
|
return await admin_dashboard_service.get_system_resources(current_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)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/user-stats")
|
@router.get("/admin/user-stats")
|
||||||
async def get_user_stats(current_user=Depends(get_current_admin_user)):
|
async def get_user_stats(current_user=Depends(get_current_admin_user)):
|
||||||
"""获取用户统计列表"""
|
return await admin_dashboard_service.get_user_stats(current_user)
|
||||||
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 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)
|
@router.post("/admin/tasks/{task_type}/{task_id}/retry")
|
||||||
users = cursor.fetchall()
|
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)
|
||||||
# 格式化返回数据
|
|
||||||
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,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.auth import get_current_user
|
||||||
from app.core.response import create_api_response
|
from app.core.response import create_api_response
|
||||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
from app.services.async_transcription_service import AsyncTranscriptionService
|
||||||
from app.services.async_meeting_service import async_meeting_service
|
from app.services.audio_upload_task_service import audio_upload_task_service
|
||||||
from app.services.audio_service import handle_audio_upload
|
|
||||||
from app.utils.audio_parser import get_audio_duration
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
@ -456,55 +454,25 @@ async def complete_upload(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. 获取文件信息
|
# 6. 提交后台任务,异步执行预处理和转录启动
|
||||||
full_path = BASE_DIR / file_path.lstrip('/')
|
full_path = BASE_DIR / file_path.lstrip('/')
|
||||||
file_size = full_path.stat().st_size
|
transcription_task_id = audio_upload_task_service.enqueue_upload_processing(
|
||||||
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,
|
|
||||||
meeting_id=request.meeting_id,
|
meeting_id=request.meeting_id,
|
||||||
|
original_file_path=file_path,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
auto_summarize=request.auto_summarize,
|
auto_summarize=request.auto_summarize,
|
||||||
background_tasks=background_tasks,
|
prompt_id=request.prompt_id,
|
||||||
prompt_id=request.prompt_id, # 传递提示词模版ID
|
|
||||||
duration=audio_duration # 传递时长参数
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 如果处理失败,返回错误
|
|
||||||
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(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="音频上传完成" + message_suffix,
|
message="音频上传完成,后台正在处理音频" + ("并准备总结" if request.auto_summarize else ""),
|
||||||
data={
|
data={
|
||||||
"meeting_id": request.meeting_id,
|
"meeting_id": request.meeting_id,
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"file_size": file_size,
|
|
||||||
"duration": audio_duration,
|
|
||||||
"task_id": transcription_task_id,
|
"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
|
"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.auth import get_current_user
|
||||||
from app.core.database import get_db_connection
|
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.services.jwt_service import jwt_service
|
||||||
from app.core.response import create_api_response
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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,))
|
cursor.execute(query, (request_body.username,))
|
||||||
user = cursor.fetchone()
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
|
@ -67,19 +81,23 @@ def login(request_body: LoginRequest, request: Request):
|
||||||
print(f"Failed to log user login: {e}")
|
print(f"Failed to log user login: {e}")
|
||||||
|
|
||||||
login_response_data = LoginResponse(
|
login_response_data = LoginResponse(
|
||||||
|
token=token,
|
||||||
|
user=UserInfo(
|
||||||
user_id=user['user_id'],
|
user_id=user['user_id'],
|
||||||
username=user['username'],
|
username=user['username'],
|
||||||
caption=user['caption'],
|
caption=user['caption'],
|
||||||
avatar_url=user['avatar_url'],
|
email=user.get('email'),
|
||||||
email=user['email'],
|
role_id=user['role_id'],
|
||||||
token=token,
|
role_name=user.get('role_name') or '普通用户',
|
||||||
role_id=user['role_id']
|
avatar_url=user.get('avatar_url'),
|
||||||
|
created_at=user['created_at']
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_api_response(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="登录成功",
|
message="登录成功",
|
||||||
data=login_response_data.dict()
|
data=login_response_data.model_dump()
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/auth/logout")
|
@router.post("/auth/logout")
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ async def get_client_downloads(
|
||||||
platform_code: Optional[str] = None,
|
platform_code: Optional[str] = None,
|
||||||
is_active: Optional[bool] = None,
|
is_active: Optional[bool] = None,
|
||||||
page: int = 1,
|
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 = """
|
query = """
|
||||||
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
|
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
|
||||||
FROM client_downloads cd
|
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'
|
AND dd.dict_type = 'client_platform'
|
||||||
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
|
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
|
||||||
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code
|
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ async def get_dict_types():
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
SELECT DISTINCT dict_type
|
SELECT DISTINCT dict_type
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE status = 1
|
WHERE status = 1
|
||||||
ORDER BY dict_type
|
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,
|
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||||
label_cn, label_en, sort_order, extension_attr,
|
label_cn, label_en, sort_order, extension_attr,
|
||||||
is_default, status, create_time
|
is_default, status, create_time
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE dict_type = %s AND status = 1
|
WHERE dict_type = %s AND status = 1
|
||||||
"""
|
"""
|
||||||
params = [dict_type]
|
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,
|
SELECT id, dict_type, dict_code, parent_code, tree_path,
|
||||||
label_cn, label_en, sort_order, extension_attr,
|
label_cn, label_en, sort_order, extension_attr,
|
||||||
is_default, status, create_time, update_time
|
is_default, status, create_time, update_time
|
||||||
FROM dict_data
|
FROM sys_dict_data
|
||||||
WHERE dict_type = %s AND dict_code = %s
|
WHERE dict_type = %s AND dict_code = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
|
|
@ -246,7 +246,7 @@ async def create_dict_data(
|
||||||
|
|
||||||
# 检查是否已存在
|
# 检查是否已存在
|
||||||
cursor.execute(
|
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)
|
(request.dict_type, request.dict_code)
|
||||||
)
|
)
|
||||||
if cursor.fetchone():
|
if cursor.fetchone():
|
||||||
|
|
@ -258,7 +258,7 @@ async def create_dict_data(
|
||||||
|
|
||||||
# 插入数据
|
# 插入数据
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO dict_data (
|
INSERT INTO sys_dict_data (
|
||||||
dict_type, dict_code, parent_code, label_cn, label_en,
|
dict_type, dict_code, parent_code, label_cn, label_en,
|
||||||
sort_order, extension_attr, is_default, status
|
sort_order, extension_attr, is_default, status
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
) 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 = 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()
|
existing = cursor.fetchone()
|
||||||
if not existing:
|
if not existing:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
@ -369,7 +369,7 @@ async def update_dict_data(
|
||||||
|
|
||||||
# 执行更新
|
# 执行更新
|
||||||
update_query = f"""
|
update_query = f"""
|
||||||
UPDATE dict_data
|
UPDATE sys_dict_data
|
||||||
SET {', '.join(update_fields)}
|
SET {', '.join(update_fields)}
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
|
|
@ -404,7 +404,7 @@ async def delete_dict_data(
|
||||||
cursor = conn.cursor(dictionary=True)
|
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()
|
existing = cursor.fetchone()
|
||||||
if not existing:
|
if not existing:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
@ -415,7 +415,7 @@ async def delete_dict_data(
|
||||||
|
|
||||||
# 检查是否有子节点
|
# 检查是否有子节点
|
||||||
cursor.execute(
|
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'],)
|
(existing['dict_code'],)
|
||||||
)
|
)
|
||||||
if cursor.fetchone()['count'] > 0:
|
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()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ async def get_external_apps(
|
||||||
list_query = f"""
|
list_query = f"""
|
||||||
SELECT ea.*, u.username as creator_username
|
SELECT ea.*, u.username as creator_username
|
||||||
FROM external_apps ea
|
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}
|
WHERE {where_clause}
|
||||||
ORDER BY ea.sort_order ASC, ea.created_at DESC
|
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.database import get_db_connection
|
||||||
from app.core.auth import get_current_admin_user
|
from app.core.auth import get_current_admin_user
|
||||||
from app.core.response import create_api_response
|
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 app.services.system_config_service import SystemConfigService
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
|
||||||
import dashscope
|
import dashscope
|
||||||
from dashscope.audio.asr import VocabularyService
|
from dashscope.audio.asr import VocabularyService
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -14,48 +12,76 @@ from http import HTTPStatus
|
||||||
|
|
||||||
router = APIRouter()
|
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
|
text: str
|
||||||
weight: int = 4
|
weight: int = 4
|
||||||
lang: str = "zh"
|
lang: str = "zh"
|
||||||
status: int = 1
|
status: int = 1
|
||||||
|
|
||||||
class UpdateHotWordRequest(BaseModel):
|
|
||||||
|
class UpdateItemRequest(BaseModel):
|
||||||
text: Optional[str] = None
|
text: Optional[str] = None
|
||||||
weight: Optional[int] = None
|
weight: Optional[int] = None
|
||||||
lang: Optional[str] = None
|
lang: Optional[str] = None
|
||||||
status: Optional[int] = 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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
cursor.execute("SELECT * FROM hot_words ORDER BY update_time DESC")
|
cursor.execute("""
|
||||||
items = cursor.fetchall()
|
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()
|
cursor.close()
|
||||||
return create_api_response(code="200", message="获取成功", data=items)
|
return create_api_response(code="200", message="获取成功", data=groups)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"获取失败: {str(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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
query = "INSERT INTO hot_words (text, weight, lang, status) VALUES (%s, %s, %s, %s)"
|
cursor.execute(
|
||||||
cursor.execute(query, (request.text, request.weight, request.lang, request.status))
|
"INSERT INTO hot_word_group (name, description, status) VALUES (%s, %s, %s)",
|
||||||
|
(request.name, request.description, request.status),
|
||||||
|
)
|
||||||
new_id = cursor.lastrowid
|
new_id = cursor.lastrowid
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
@ -63,111 +89,207 @@ async def create_hot_word(request: CreateHotWordRequest, current_user: dict = De
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"创建失败: {str(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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
update_fields = []
|
fields, params = [], []
|
||||||
params = []
|
if request.name is not None:
|
||||||
if request.text is not None:
|
fields.append("name = %s"); params.append(request.name)
|
||||||
update_fields.append("text = %s")
|
if request.description is not None:
|
||||||
params.append(request.text)
|
fields.append("description = %s"); params.append(request.description)
|
||||||
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)
|
|
||||||
if request.status is not None:
|
if request.status is not None:
|
||||||
update_fields.append("status = %s")
|
fields.append("status = %s"); params.append(request.status)
|
||||||
params.append(request.status)
|
if not fields:
|
||||||
|
|
||||||
if not update_fields:
|
|
||||||
return create_api_response(code="400", message="无更新内容")
|
return create_api_response(code="400", message="无更新内容")
|
||||||
|
|
||||||
query = f"UPDATE hot_words SET {', '.join(update_fields)} WHERE id = %s"
|
|
||||||
params.append(id)
|
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()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
return create_api_response(code="200", message="更新成功")
|
return create_api_response(code="200", message="更新成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"更新失败: {str(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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor()
|
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()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
return create_api_response(code="200", message="删除成功")
|
return create_api_response(code="200", message="删除成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"删除失败: {str(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:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
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:
|
vocabulary_list = [{"text": it["text"], "weight": it["weight"], "lang": it["lang"]} for it in items]
|
||||||
return create_api_response(code="400", message="没有启用的热词可同步")
|
|
||||||
|
# ASR 模型名(同步时需要)
|
||||||
|
asr_model_name = SystemConfigService.get_config_attribute('audio_model', 'model', 'paraformer-v2')
|
||||||
|
existing_vocab_id = group.get("vocabulary_id")
|
||||||
|
|
||||||
# 3. 调用阿里云 API
|
|
||||||
service = VocabularyService()
|
service = VocabularyService()
|
||||||
vocab_id = existing_vocab_id
|
vocab_id = existing_vocab_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if existing_vocab_id:
|
if existing_vocab_id:
|
||||||
# 尝试更新现有的热词表
|
|
||||||
try:
|
try:
|
||||||
service.update_vocabulary(
|
service.update_vocabulary(
|
||||||
vocabulary_id=existing_vocab_id,
|
vocabulary_id=existing_vocab_id,
|
||||||
vocabulary=vocabulary_list
|
vocabulary=vocabulary_list,
|
||||||
)
|
)
|
||||||
# 更新成功,保持原有ID
|
except Exception:
|
||||||
except Exception as update_error:
|
existing_vocab_id = None # 更新失败,重建
|
||||||
# 如果更新失败(如资源不存在),尝试创建新的
|
|
||||||
print(f"Update vocabulary failed: {update_error}, trying to create new one.")
|
|
||||||
existing_vocab_id = None # 重置,触发创建逻辑
|
|
||||||
|
|
||||||
if not existing_vocab_id:
|
if not existing_vocab_id:
|
||||||
# 创建新的热词表
|
|
||||||
vocab_id = service.create_vocabulary(
|
vocab_id = service.create_vocabulary(
|
||||||
prefix='imeeting',
|
prefix="imeeting",
|
||||||
target_model='paraformer-v2',
|
target_model=asr_model_name,
|
||||||
vocabulary=vocabulary_list
|
vocabulary=vocabulary_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as api_error:
|
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
|
# 回写 vocabulary_id 到热词组
|
||||||
if vocab_id:
|
cursor.execute(
|
||||||
SystemConfigService.set_config(
|
"UPDATE hot_word_group SET vocabulary_id = %s, last_sync_time = NOW() WHERE id = %s",
|
||||||
SystemConfigService.ASR_VOCABULARY_ID,
|
(vocab_id, id),
|
||||||
vocab_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:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"同步异常: {str(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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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 = []
|
where_clauses = []
|
||||||
params = []
|
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,
|
kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at,
|
||||||
u.username as created_by_name
|
u.username as created_by_name
|
||||||
FROM knowledge_bases kb
|
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
|
WHERE kb.kb_id = %s
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (kb_id,))
|
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 pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import Optional, List
|
||||||
|
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
from app.core.response import create_api_response
|
from app.core.response import create_api_response
|
||||||
|
from app.models.models import PromptCreate, PromptUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
class PromptConfigItem(BaseModel):
|
||||||
id: int
|
prompt_id: int
|
||||||
creator_id: int
|
is_enabled: bool = True
|
||||||
created_at: str
|
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")
|
@router.post("/prompts")
|
||||||
def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
def create_prompt(
|
||||||
"""Create a new 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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
try:
|
try:
|
||||||
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
|
is_admin = _is_admin(current_user)
|
||||||
if prompt.is_default:
|
requested_is_system = bool(getattr(prompt, "is_system", False))
|
||||||
cursor.execute(
|
is_system = 1 if (is_admin and requested_is_system) else 0
|
||||||
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s",
|
|
||||||
(prompt.task_type,)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
owner_user_id = current_user["user_id"]
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id)
|
"""
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
SELECT COUNT(*) as cnt
|
||||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
|
||||||
prompt.is_active, current_user["user_id"])
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
new_id = cursor.lastrowid
|
|
||||||
return create_api_response(
|
|
||||||
code="200",
|
|
||||||
message="提示词创建成功",
|
|
||||||
data={"id": new_id, **prompt.dict()}
|
|
||||||
)
|
|
||||||
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}")
|
|
||||||
|
|
||||||
@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
|
FROM prompts
|
||||||
WHERE task_type = %s AND is_active = TRUE
|
WHERE task_type = %s
|
||||||
ORDER BY is_default DESC, created_at DESC""",
|
AND is_system = %s
|
||||||
(task_type,)
|
AND creator_id = %s
|
||||||
|
""",
|
||||||
|
(prompt.task_type, is_system, owner_user_id),
|
||||||
)
|
)
|
||||||
prompts = cursor.fetchall()
|
count = (cursor.fetchone() or {}).get("cnt", 0)
|
||||||
return create_api_response(
|
is_default = 1 if count == 0 else (1 if prompt.is_default else 0)
|
||||||
code="200",
|
|
||||||
message="获取启用模版列表成功",
|
if is_default:
|
||||||
data={"prompts": prompts}
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
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, `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()
|
||||||
|
return create_api_response(code="200", message="提示词模版创建成功", data={"id": prompt_id})
|
||||||
|
except Exception as e:
|
||||||
|
connection.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/prompts")
|
@router.get("/prompts")
|
||||||
def get_prompts(
|
def get_prompts(
|
||||||
task_type: Optional[str] = None,
|
task_type: Optional[str] = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
size: int = 50,
|
size: int = 12,
|
||||||
current_user: dict = Depends(get_current_user)
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
# 构建 WHERE 条件
|
is_admin = _is_admin(current_user)
|
||||||
where_conditions = ["creator_id = %s"]
|
where_conditions = []
|
||||||
params = [current_user["user_id"]]
|
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:
|
if task_type:
|
||||||
where_conditions.append("task_type = %s")
|
where_conditions.append("p.task_type = %s")
|
||||||
params.append(task_type)
|
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)
|
||||||
|
|
||||||
# 获取总数
|
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
|
||||||
cursor.execute(
|
|
||||||
f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}",
|
|
||||||
tuple(params)
|
|
||||||
)
|
|
||||||
total = cursor.fetchone()['total']
|
|
||||||
|
|
||||||
# 获取分页数据
|
cursor.execute(f"SELECT COUNT(*) as total FROM prompts p WHERE {where_clause}", tuple(params))
|
||||||
offset = (page - 1) * size
|
total = (cursor.fetchone() or {}).get("total", 0)
|
||||||
|
|
||||||
|
offset = max(page - 1, 0) * size
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
f"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
f"""
|
||||||
FROM prompts
|
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}
|
WHERE {where_clause}
|
||||||
ORDER BY created_at DESC
|
ORDER BY p.is_system DESC, p.task_type ASC, p.is_default DESC, p.created_at DESC
|
||||||
LIMIT %s OFFSET %s""",
|
LIMIT %s OFFSET %s
|
||||||
tuple(params + [size, offset])
|
""",
|
||||||
|
tuple(params + [size, offset]),
|
||||||
)
|
)
|
||||||
prompts = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
return create_api_response(
|
return create_api_response(
|
||||||
code="200",
|
code="200",
|
||||||
message="获取提示词列表成功",
|
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)):
|
@router.get("/prompts/active/{task_type}")
|
||||||
"""Get a single prompt by its ID."""
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
|
"""
|
||||||
FROM prompts WHERE id = %s""",
|
SELECT p.id, p.name, p.`desc`, p.content, p.is_default, p.is_system, p.creator_id,
|
||||||
(prompt_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()
|
prompts = cursor.fetchall()
|
||||||
if not prompt:
|
|
||||||
return create_api_response(code="404", message="提示词不存在")
|
|
||||||
return create_api_response(code="200", message="获取提示词成功", data=prompt)
|
|
||||||
|
|
||||||
@router.put("/prompts/{prompt_id}")
|
enabled = [x for x in prompts if x.get("is_enabled") == 1]
|
||||||
def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends(get_current_user)):
|
if enabled:
|
||||||
"""Update an existing prompt."""
|
result = enabled
|
||||||
print(f"[UPDATE PROMPT] prompt_id={prompt_id}, type={type(prompt_id)}")
|
else:
|
||||||
print(f"[UPDATE PROMPT] user_id={current_user['user_id']}")
|
result = prompts
|
||||||
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}")
|
|
||||||
|
|
||||||
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
try:
|
try:
|
||||||
# 先检查记录是否存在
|
requested_ids = [int(item.prompt_id) for item in request.items if item.is_enabled]
|
||||||
cursor.execute("SELECT id, creator_id FROM prompts WHERE id = %s", (prompt_id,))
|
if requested_ids:
|
||||||
existing = cursor.fetchone()
|
placeholders = ",".join(["%s"] * len(requested_ids))
|
||||||
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}")
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s",
|
f"""
|
||||||
(prompt.task_type, prompt_id)
|
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(
|
cursor.execute(
|
||||||
"""UPDATE prompts
|
"DELETE FROM prompt_config WHERE user_id = %s AND task_type = %s",
|
||||||
SET name = %s, task_type = %s, content = %s, is_default = %s, is_active = %s
|
(current_user["user_id"], task_type),
|
||||||
WHERE id = %s""",
|
)
|
||||||
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
|
|
||||||
prompt.is_active, prompt_id)
|
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),
|
||||||
)
|
)
|
||||||
rows_affected = cursor.rowcount
|
|
||||||
print(f"[UPDATE PROMPT] UPDATE affected {rows_affected} rows (0 means no changes needed)")
|
|
||||||
|
|
||||||
# 注意:rowcount=0 不代表记录不存在,可能是所有字段值都相同
|
|
||||||
# 我们已经在上面确认了记录存在,所以这里直接提交即可
|
|
||||||
connection.commit()
|
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:
|
except Exception as e:
|
||||||
print(f"[UPDATE PROMPT] Exception: {type(e).__name__}: {e}")
|
connection.rollback()
|
||||||
if "Duplicate entry" in str(e):
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
return create_api_response(code="400", message="提示词名称已存在")
|
|
||||||
return create_api_response(code="500", message=f"更新提示词失败: {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}")
|
@router.delete("/prompts/{prompt_id}")
|
||||||
def delete_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
# 首先检查提示词是否存在以及是否属于当前用户
|
try:
|
||||||
cursor.execute(
|
cursor.execute("SELECT id, creator_id, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
|
||||||
"SELECT creator_id FROM prompts WHERE id = %s",
|
existing = cursor.fetchone()
|
||||||
(prompt_id,)
|
if not existing:
|
||||||
)
|
raise HTTPException(status_code=404, detail="模版不存在")
|
||||||
prompt = cursor.fetchone()
|
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,))
|
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return create_api_response(code="200", message="提示词删除成功")
|
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 fastapi import APIRouter, Depends, UploadFile, File
|
||||||
from typing import Optional
|
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.database import get_db_connection
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
from app.core.response import create_api_response
|
from app.core.response import create_api_response
|
||||||
|
|
@ -13,6 +13,7 @@ import re
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -25,6 +26,59 @@ def validate_email(email: str) -> bool:
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return hashlib.sha256(password.encode()).hexdigest()
|
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")
|
@router.get("/roles")
|
||||||
def get_all_roles(current_user: dict = Depends(get_current_user)):
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
roles = cursor.fetchall()
|
||||||
return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles])
|
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
|
if current_user['role_id'] != 1: # 1 is admin
|
||||||
return create_api_response(code="403", message="仅管理员有权限创建用户")
|
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="邮箱格式不正确")
|
return create_api_response(code="400", message="邮箱格式不正确")
|
||||||
|
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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():
|
if cursor.fetchone():
|
||||||
return create_api_response(code="400", message="用户名已存在")
|
return create_api_response(code="400", message="用户名已存在")
|
||||||
|
|
||||||
password = request.password if request.password else SystemConfigService.get_default_reset_password()
|
password = request.password if request.password else SystemConfigService.get_default_reset_password()
|
||||||
hashed_password = hash_password(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()
|
created_at = datetime.datetime.utcnow()
|
||||||
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
|
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
@ -74,13 +128,13 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
existing_user = cursor.fetchone()
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
return create_api_response(code="404", message="用户不存在")
|
return create_api_response(code="404", message="用户不存在")
|
||||||
|
|
||||||
if request.username and request.username != existing_user['username']:
|
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():
|
if cursor.fetchone():
|
||||||
return create_api_response(code="400", message="用户名已存在")
|
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
|
'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))
|
cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['avatar_url'], update_data['role_id'], user_id))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
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
|
FROM sys_users u
|
||||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
LEFT JOIN sys_roles r ON u.role_id = r.role_id
|
||||||
WHERE u.user_id = %s
|
WHERE u.user_id = %s
|
||||||
''', (user_id,))
|
''', (user_id,))
|
||||||
updated_user = cursor.fetchone()
|
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'],
|
avatar_url=updated_user['avatar_url'],
|
||||||
created_at=updated_user['created_at'],
|
created_at=updated_user['created_at'],
|
||||||
role_id=updated_user['role_id'],
|
role_id=updated_user['role_id'],
|
||||||
role_name=updated_user['role_name'],
|
role_name=updated_user['role_name'] or '普通用户'
|
||||||
meetings_created=0,
|
|
||||||
meetings_attended=0
|
|
||||||
)
|
)
|
||||||
return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict())
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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():
|
if not cursor.fetchone():
|
||||||
return create_api_response(code="404", message="用户不存在")
|
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()
|
connection.commit()
|
||||||
|
|
||||||
return create_api_response(code="200", message="用户删除成功")
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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():
|
if not cursor.fetchone():
|
||||||
return create_api_response(code="404", message="用户不存在")
|
return create_api_response(code="404", message="用户不存在")
|
||||||
|
|
||||||
hashed_password = hash_password(SystemConfigService.get_default_reset_password())
|
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))
|
cursor.execute(query, (hashed_password, user_id))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
|
|
@ -185,7 +237,7 @@ def get_all_users(
|
||||||
count_params.extend([search_pattern, search_pattern])
|
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:
|
if where_conditions:
|
||||||
count_query += " WHERE " + " AND ".join(where_conditions)
|
count_query += " WHERE " + " AND ".join(where_conditions)
|
||||||
|
|
||||||
|
|
@ -197,12 +249,16 @@ def get_all_users(
|
||||||
# 主查询
|
# 主查询
|
||||||
query = '''
|
query = '''
|
||||||
SELECT
|
SELECT
|
||||||
u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
|
u.user_id,
|
||||||
r.role_name,
|
u.username,
|
||||||
(SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created,
|
u.caption,
|
||||||
(SELECT COUNT(*) FROM attendees WHERE user_id = u.user_id) as meetings_attended
|
u.email,
|
||||||
FROM users u
|
u.avatar_url,
|
||||||
LEFT JOIN roles r ON u.role_id = r.role_id
|
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 = []
|
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)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
user_query = '''
|
user_query = '''
|
||||||
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
|
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
|
||||||
FROM users u
|
COALESCE(r.role_name, '普通用户') AS role_name
|
||||||
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
|
WHERE u.user_id = %s
|
||||||
'''
|
'''
|
||||||
cursor.execute(user_query, (user_id,))
|
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:
|
if not user:
|
||||||
return create_api_response(code="404", message="用户不存在")
|
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_info = UserInfo(
|
||||||
user_id=user['user_id'],
|
user_id=user['user_id'],
|
||||||
username=user['username'],
|
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'],
|
avatar_url=user['avatar_url'],
|
||||||
created_at=user['created_at'],
|
created_at=user['created_at'],
|
||||||
role_id=user['role_id'],
|
role_id=user['role_id'],
|
||||||
role_name=user['role_name'],
|
role_name=user['role_name']
|
||||||
meetings_created=meetings_created,
|
|
||||||
meetings_attended=meetings_attended
|
|
||||||
)
|
)
|
||||||
return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict())
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
user = cursor.fetchone()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -283,7 +330,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
|
||||||
return create_api_response(code="400", message="旧密码错误")
|
return create_api_response(code="400", message="旧密码错误")
|
||||||
|
|
||||||
new_password_hash = hash_password(request.new_password)
|
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()
|
connection.commit()
|
||||||
|
|
||||||
return create_api_response(code="200", message="密码修改成功")
|
return create_api_response(code="200", message="密码修改成功")
|
||||||
|
|
@ -305,7 +352,7 @@ def upload_user_avatar(
|
||||||
return create_api_response(code="400", message="不支持的文件类型")
|
return create_api_response(code="400", message="不支持的文件类型")
|
||||||
|
|
||||||
# Ensure upload directory exists: AVATAR_DIR / str(user_id)
|
# 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():
|
if not user_avatar_dir.exists():
|
||||||
os.makedirs(user_avatar_dir)
|
os.makedirs(user_avatar_dir)
|
||||||
|
|
||||||
|
|
@ -321,13 +368,57 @@ def upload_user_avatar(
|
||||||
# AVATAR_DIR is uploads/user/avatar
|
# AVATAR_DIR is uploads/user/avatar
|
||||||
# file path is uploads/user/avatar/{user_id}/{filename}
|
# file path is uploads/user/avatar/{user_id}/{filename}
|
||||||
# URL should be /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
|
# Update database
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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()
|
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:
|
try:
|
||||||
template_data = VoiceprintTemplate(
|
template_data = VoiceprintTemplate(
|
||||||
template_text=SystemConfigService.get_voiceprint_template(),
|
content=SystemConfigService.get_voiceprint_template(),
|
||||||
duration_seconds=SystemConfigService.get_voiceprint_duration(),
|
duration_seconds=SystemConfigService.get_voiceprint_duration(),
|
||||||
sample_rate=SystemConfigService.get_voiceprint_sample_rate(),
|
sample_rate=SystemConfigService.get_voiceprint_sample_rate(),
|
||||||
channels=SystemConfigService.get_voiceprint_channels()
|
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:
|
except Exception as e:
|
||||||
return create_api_response(code="500", message=f"获取朗读模板失败: {str(e)}")
|
return create_api_response(code="500", message=f"获取朗读模板失败: {str(e)}")
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ async def upload_voiceprint(
|
||||||
|
|
||||||
try:
|
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)
|
user_voiceprint_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 生成文件名:时间戳.wav
|
# 生成文件名:时间戳.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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
cursor.execute(
|
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_id,)
|
||||||
)
|
)
|
||||||
user = cursor.fetchone()
|
user = cursor.fetchone()
|
||||||
|
|
@ -67,7 +67,7 @@ def get_optional_current_user(request: Request) -> Optional[dict]:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
cursor.execute(
|
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,)
|
(user_id,)
|
||||||
)
|
)
|
||||||
return cursor.fetchone()
|
return cursor.fetchone()
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,9 @@ import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
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
|
BASE_DIR = Path(__file__).parent.parent.parent
|
||||||
|
REPO_DIR = BASE_DIR.parent
|
||||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||||
AUDIO_DIR = UPLOAD_DIR / "audio"
|
AUDIO_DIR = UPLOAD_DIR / "audio"
|
||||||
TEMP_UPLOAD_DIR = UPLOAD_DIR / "temp_audio"
|
TEMP_UPLOAD_DIR = UPLOAD_DIR / "temp_audio"
|
||||||
|
|
@ -16,8 +13,63 @@ MARKDOWN_DIR = UPLOAD_DIR / "markdown"
|
||||||
CLIENT_DIR = UPLOAD_DIR / "clients"
|
CLIENT_DIR = UPLOAD_DIR / "clients"
|
||||||
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
|
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
|
||||||
USER_DIR = UPLOAD_DIR / "user"
|
USER_DIR = UPLOAD_DIR / "user"
|
||||||
VOICEPRINT_DIR = USER_DIR / "voiceprint"
|
LEGACY_VOICEPRINT_DIR = USER_DIR / "voiceprint"
|
||||||
AVATAR_DIR = USER_DIR / "avatar"
|
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"}
|
ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"}
|
||||||
|
|
@ -35,24 +87,24 @@ MARKDOWN_DIR.mkdir(exist_ok=True)
|
||||||
CLIENT_DIR.mkdir(exist_ok=True)
|
CLIENT_DIR.mkdir(exist_ok=True)
|
||||||
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
|
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
|
||||||
USER_DIR.mkdir(exist_ok=True)
|
USER_DIR.mkdir(exist_ok=True)
|
||||||
VOICEPRINT_DIR.mkdir(exist_ok=True)
|
LEGACY_VOICEPRINT_DIR.mkdir(exist_ok=True)
|
||||||
AVATAR_DIR.mkdir(exist_ok=True)
|
LEGACY_AVATAR_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
DATABASE_CONFIG = {
|
DATABASE_CONFIG = {
|
||||||
'host': os.getenv('DB_HOST', '127.0.0.1'),
|
'host': _get_env('DB_HOST', 'MYSQL_HOST', default='127.0.0.1', allow_blank=False),
|
||||||
'user': os.getenv('DB_USER', 'root'),
|
'user': _get_env('DB_USER', 'MYSQL_USER', default='root', allow_blank=False),
|
||||||
'password': os.getenv('DB_PASSWORD', ''),
|
'password': _get_env('DB_PASSWORD', 'MYSQL_PASSWORD', default=''),
|
||||||
'database': os.getenv('DB_NAME', 'imeeting'),
|
'database': _get_env('DB_NAME', 'MYSQL_DATABASE', default='imeeting', allow_blank=False),
|
||||||
'port': int(os.getenv('DB_PORT', '3306')),
|
'port': _get_int_env('DB_PORT', 'MYSQL_PORT', default=3306),
|
||||||
'charset': 'utf8mb4'
|
'charset': 'utf8mb4'
|
||||||
}
|
}
|
||||||
|
|
||||||
# API配置
|
# API配置
|
||||||
API_CONFIG = {
|
API_CONFIG = {
|
||||||
'host': os.getenv('API_HOST', '0.0.0.0'),
|
'host': _get_env('API_HOST', default='0.0.0.0', allow_blank=False),
|
||||||
'port': int(os.getenv('API_PORT', '8000'))
|
'port': _get_int_env('API_PORT', default=8000)
|
||||||
}
|
}
|
||||||
|
|
||||||
# 七牛云配置
|
# 七牛云配置
|
||||||
|
|
@ -63,25 +115,27 @@ API_CONFIG = {
|
||||||
|
|
||||||
# 应用配置
|
# 应用配置
|
||||||
APP_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配置
|
||||||
REDIS_CONFIG = {
|
REDIS_CONFIG = {
|
||||||
'host': os.getenv('REDIS_HOST', '127.0.0.1'),
|
'host': _get_env('REDIS_HOST', default='127.0.0.1', allow_blank=False),
|
||||||
'port': int(os.getenv('REDIS_PORT', '6379')),
|
'port': _get_int_env('REDIS_PORT', default=6379),
|
||||||
'db': int(os.getenv('REDIS_DB', '0')),
|
'db': _get_int_env('REDIS_DB', default=0),
|
||||||
'password': os.getenv('REDIS_PASSWORD', ''),
|
'password': _get_env('REDIS_PASSWORD', default=''),
|
||||||
'decode_responses': True
|
'decode_responses': True
|
||||||
}
|
}
|
||||||
|
|
||||||
# Dashscope (Tongyi Qwen) API Key
|
|
||||||
QWEN_API_KEY = os.getenv('QWEN_API_KEY', 'sk-c2bf06ea56b4491ea3d1e37fdb472b8f')
|
|
||||||
|
|
||||||
# 转录轮询配置 - 用于 upload-audio-complete 接口
|
# 转录轮询配置 - 用于 upload-audio-complete 接口
|
||||||
TRANSCRIPTION_POLL_CONFIG = {
|
TRANSCRIPTION_POLL_CONFIG = {
|
||||||
'poll_interval': int(os.getenv('TRANSCRIPTION_POLL_INTERVAL', '10')), # 轮询间隔:10秒
|
'poll_interval': _get_int_env('TRANSCRIPTION_POLL_INTERVAL', default=10), # 轮询间隔:10秒
|
||||||
'max_wait_time': int(os.getenv('TRANSCRIPTION_MAX_WAIT_TIME', '1800')), # 最大等待:30分钟
|
'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
|
connection = None
|
||||||
try:
|
try:
|
||||||
connection = mysql.connector.connect(**DATABASE_CONFIG)
|
connection = mysql.connector.connect(**DATABASE_CONFIG)
|
||||||
yield connection
|
|
||||||
except Error as e:
|
except Error as e:
|
||||||
print(f"数据库连接错误: {e}")
|
print(f"数据库连接错误: {e}")
|
||||||
raise HTTPException(status_code=500, detail="数据库连接失败")
|
raise HTTPException(status_code=500, detail="数据库连接失败")
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
finally:
|
finally:
|
||||||
if connection and connection.is_connected():
|
if connection and connection.is_connected():
|
||||||
try:
|
try:
|
||||||
# 确保清理任何未读结果
|
|
||||||
if connection.unread_result:
|
if connection.unread_result:
|
||||||
connection.consume_results()
|
connection.consume_results()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,26 @@ from app.services.terminal_service import terminal_service
|
||||||
from app.services.jwt_service import jwt_service
|
from app.services.jwt_service import jwt_service
|
||||||
from app.core.response import create_api_response
|
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):
|
class TerminalCheckMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行
|
# 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,32 @@
|
||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# 添加项目根目录到 Python 路径
|
# 添加项目根目录到 Python 路径
|
||||||
# 无论从哪里运行,都能正确找到 app 模块
|
|
||||||
current_file = Path(__file__).resolve()
|
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:
|
if str(project_root) not in sys.path:
|
||||||
sys.path.insert(0, str(project_root))
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
import uvicorn
|
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(
|
from app.app_factory import create_app
|
||||||
title="iMeeting API",
|
from app.core.config import API_CONFIG
|
||||||
description="iMeeting API说明",
|
|
||||||
version="1.1.0",
|
|
||||||
docs_url=None, # 禁用默认docs,使用自定义CDN
|
|
||||||
redoc_url=None
|
|
||||||
)
|
|
||||||
|
|
||||||
# 添加终端检查中间件 (在CORS之前添加,以便位于CORS内部)
|
|
||||||
app.add_middleware(TerminalCheckMiddleware)
|
|
||||||
|
|
||||||
# 添加CORS中间件
|
app = create_app()
|
||||||
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")
|
|
||||||
|
|
||||||
# 包含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__":
|
if __name__ == "__main__":
|
||||||
# 简单的uvicorn配置,避免参数冲突
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"app.main:app",
|
"app.main:app",
|
||||||
host=API_CONFIG['host'],
|
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 pydantic import BaseModel, Field, EmailStr
|
||||||
from typing import Optional, Union, List
|
from typing import List, Optional, Any, Dict, Union
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
# 认证相关模型
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: 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):
|
class RoleInfo(BaseModel):
|
||||||
role_id: int
|
role_id: int
|
||||||
role_name: str
|
role_name: str
|
||||||
|
|
@ -23,102 +15,111 @@ class UserInfo(BaseModel):
|
||||||
user_id: int
|
user_id: int
|
||||||
username: str
|
username: str
|
||||||
caption: str
|
caption: str
|
||||||
email: EmailStr
|
email: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
|
||||||
created_at: datetime.datetime
|
|
||||||
meetings_created: int
|
|
||||||
meetings_attended: int
|
|
||||||
role_id: int
|
role_id: int
|
||||||
role_name: str
|
role_name: str
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
user: UserInfo
|
||||||
|
|
||||||
class UserListResponse(BaseModel):
|
class UserListResponse(BaseModel):
|
||||||
users: list[UserInfo]
|
users: List[UserInfo]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
class CreateUserRequest(BaseModel):
|
class CreateUserRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
caption: str
|
caption: str
|
||||||
email: EmailStr
|
email: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
role_id: int
|
role_id: int = 2
|
||||||
|
|
||||||
class UpdateUserRequest(BaseModel):
|
class UpdateUserRequest(BaseModel):
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
caption: Optional[str] = None
|
caption: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
avatar_url: Optional[str] = None
|
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
|
||||||
class UserLog(BaseModel):
|
class UserLog(BaseModel):
|
||||||
log_id: int
|
log_id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
action_type: str
|
username: str
|
||||||
|
action: str
|
||||||
|
details: Optional[str] = None
|
||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
user_agent: Optional[str] = None
|
|
||||||
metadata: Optional[dict] = None
|
|
||||||
created_at: datetime.datetime
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
# 会议相关模型
|
||||||
class AttendeeInfo(BaseModel):
|
class AttendeeInfo(BaseModel):
|
||||||
user_id: int
|
user_id: Optional[int] = None
|
||||||
|
username: Optional[str] = None
|
||||||
caption: str
|
caption: str
|
||||||
|
|
||||||
class Tag(BaseModel):
|
class Tag(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
color: str
|
|
||||||
|
|
||||||
class TranscriptionTaskStatus(BaseModel):
|
class TranscriptionTaskStatus(BaseModel):
|
||||||
task_id: str
|
task_id: str
|
||||||
status: str # 'pending', 'processing', 'completed', 'failed'
|
status: str
|
||||||
progress: int # 0-100
|
progress: int
|
||||||
meeting_id: int
|
message: Optional[str] = None
|
||||||
created_at: Optional[str] = None
|
|
||||||
updated_at: Optional[str] = None
|
|
||||||
completed_at: Optional[str] = None
|
|
||||||
error_message: Optional[str] = None
|
|
||||||
|
|
||||||
class Meeting(BaseModel):
|
class Meeting(BaseModel):
|
||||||
meeting_id: int
|
meeting_id: int
|
||||||
title: str
|
title: str
|
||||||
meeting_time: Optional[datetime.datetime]
|
meeting_time: datetime.datetime
|
||||||
summary: Optional[str]
|
description: Optional[str] = None
|
||||||
created_at: datetime.datetime
|
|
||||||
attendees: Union[List[str], List[AttendeeInfo]] # Support both formats
|
|
||||||
creator_id: int
|
creator_id: int
|
||||||
creator_username: str
|
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_file_path: Optional[str] = None
|
||||||
audio_duration: Optional[float] = None
|
audio_duration: Optional[float] = None
|
||||||
prompt_name: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
transcription_status: Optional[TranscriptionTaskStatus] = 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
|
access_password: Optional[str] = None
|
||||||
|
|
||||||
class TranscriptSegment(BaseModel):
|
class TranscriptSegment(BaseModel):
|
||||||
segment_id: int
|
segment_id: int
|
||||||
meeting_id: int
|
speaker_id: int
|
||||||
speaker_id: Optional[int] = None # AI解析的原始结果
|
|
||||||
speaker_tag: str
|
speaker_tag: str
|
||||||
start_time_ms: int
|
start_time_ms: int
|
||||||
end_time_ms: int
|
end_time_ms: int
|
||||||
text_content: str
|
text_content: str
|
||||||
|
|
||||||
class CreateMeetingRequest(BaseModel):
|
class CreateMeetingRequest(BaseModel):
|
||||||
user_id: int
|
|
||||||
title: str
|
title: str
|
||||||
meeting_time: Optional[datetime.datetime]
|
meeting_time: datetime.datetime
|
||||||
attendee_ids: list[int]
|
attendee_ids: List[int] = Field(default_factory=list)
|
||||||
tags: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
tags: Optional[str] = None # 逗号分隔
|
||||||
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class UpdateMeetingRequest(BaseModel):
|
class UpdateMeetingRequest(BaseModel):
|
||||||
title: str
|
title: Optional[str] = None
|
||||||
meeting_time: Optional[datetime.datetime]
|
meeting_time: Optional[datetime.datetime] = None
|
||||||
summary: Optional[str]
|
attendee_ids: Optional[List[int]] = None
|
||||||
attendee_ids: list[int]
|
description: Optional[str] = None
|
||||||
tags: Optional[str] = None
|
tags: Optional[str] = None
|
||||||
|
summary: Optional[str] = None
|
||||||
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class SpeakerTagUpdateRequest(BaseModel):
|
class SpeakerTagUpdateRequest(BaseModel):
|
||||||
speaker_id: int # 使用原始speaker_id(整数)
|
speaker_id: int
|
||||||
new_tag: str
|
new_tag: str
|
||||||
|
|
||||||
class BatchSpeakerTagUpdateRequest(BaseModel):
|
class BatchSpeakerTagUpdateRequest(BaseModel):
|
||||||
|
|
@ -135,45 +136,66 @@ class PasswordChangeRequest(BaseModel):
|
||||||
old_password: str
|
old_password: str
|
||||||
new_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):
|
class KnowledgeBase(BaseModel):
|
||||||
kb_id: int
|
kb_id: int
|
||||||
title: str
|
title: str
|
||||||
content: Optional[str] = None
|
content: str
|
||||||
creator_id: int
|
creator_id: int
|
||||||
creator_caption: str # To show in the UI
|
created_by_name: str
|
||||||
is_shared: bool
|
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
|
created_at: datetime.datetime
|
||||||
updated_at: datetime.datetime
|
updated_at: datetime.datetime
|
||||||
source_meeting_count: Optional[int] = 0
|
source_meeting_count: int
|
||||||
created_by_name: Optional[str] = None
|
source_meetings: Optional[List[Meeting]] = None
|
||||||
|
user_prompt: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class KnowledgeBaseTask(BaseModel):
|
class KnowledgeBaseTask(BaseModel):
|
||||||
task_id: str
|
task_id: str
|
||||||
user_id: int
|
|
||||||
kb_id: int
|
|
||||||
user_prompt: Optional[str] = None
|
|
||||||
status: str
|
status: str
|
||||||
progress: int
|
progress: int
|
||||||
error_message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
created_at: datetime.datetime
|
result: Optional[str] = None
|
||||||
updated_at: datetime.datetime
|
|
||||||
completed_at: Optional[datetime.datetime] = None
|
|
||||||
|
|
||||||
class CreateKnowledgeBaseRequest(BaseModel):
|
class CreateKnowledgeBaseRequest(BaseModel):
|
||||||
title: Optional[str] = None # 改为可选,后台自动生成
|
|
||||||
is_shared: bool
|
|
||||||
user_prompt: Optional[str] = None
|
user_prompt: Optional[str] = None
|
||||||
source_meeting_ids: Optional[str] = None
|
source_meeting_ids: str # 逗号分隔
|
||||||
tags: Optional[str] = None
|
is_shared: bool = False
|
||||||
prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版
|
prompt_id: Optional[int] = None
|
||||||
|
|
||||||
class UpdateKnowledgeBaseRequest(BaseModel):
|
class UpdateKnowledgeBaseRequest(BaseModel):
|
||||||
title: str
|
title: Optional[str] = None
|
||||||
content: Optional[str] = None
|
content: Optional[str] = None
|
||||||
tags: Optional[str] = None
|
is_shared: Optional[bool] = None
|
||||||
|
|
||||||
class KnowledgeBaseListResponse(BaseModel):
|
class KnowledgeBaseListResponse(BaseModel):
|
||||||
kbs: List[KnowledgeBase]
|
kbs: List[KnowledgeBase]
|
||||||
|
|
@ -182,70 +204,62 @@ class KnowledgeBaseListResponse(BaseModel):
|
||||||
# 客户端下载相关模型
|
# 客户端下载相关模型
|
||||||
class ClientDownload(BaseModel):
|
class ClientDownload(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
platform_type: Optional[str] = None # 兼容旧版:'mobile', 'desktop', 'terminal'
|
platform_code: str
|
||||||
platform_name: Optional[str] = None # 兼容旧版:'ios', 'android', 'windows', 'mac_intel', 'mac_m', 'linux'
|
platform_type: str # mobile, desktop, terminal
|
||||||
platform_code: str # 新版平台编码,关联 dict_data.dict_code
|
platform_name: str
|
||||||
version: str
|
version: str
|
||||||
version_code: int
|
version_code: int
|
||||||
download_url: str
|
download_url: str
|
||||||
file_size: Optional[int] = None
|
file_size: Optional[int] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
|
min_system_version: Optional[str] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_latest: bool
|
is_latest: bool
|
||||||
min_system_version: Optional[str] = None
|
|
||||||
created_at: datetime.datetime
|
created_at: datetime.datetime
|
||||||
updated_at: datetime.datetime
|
updated_at: datetime.datetime
|
||||||
created_by: Optional[int] = None
|
|
||||||
|
|
||||||
class CreateClientDownloadRequest(BaseModel):
|
class CreateClientDownloadRequest(BaseModel):
|
||||||
platform_type: Optional[str] = None # 兼容旧版
|
platform_code: str
|
||||||
platform_name: Optional[str] = None # 兼容旧版
|
platform_type: Optional[str] = None
|
||||||
platform_code: str # 必填,关联 dict_data
|
platform_name: Optional[str] = None
|
||||||
version: str
|
version: str
|
||||||
version_code: int
|
version_code: int
|
||||||
download_url: str
|
download_url: str
|
||||||
file_size: Optional[int] = None
|
file_size: Optional[int] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
|
min_system_version: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
is_latest: bool = False
|
is_latest: bool = False
|
||||||
min_system_version: Optional[str] = None
|
|
||||||
|
|
||||||
class UpdateClientDownloadRequest(BaseModel):
|
class UpdateClientDownloadRequest(BaseModel):
|
||||||
|
platform_code: Optional[str] = None
|
||||||
platform_type: Optional[str] = None
|
platform_type: Optional[str] = None
|
||||||
platform_name: Optional[str] = None
|
platform_name: Optional[str] = None
|
||||||
platform_code: Optional[str] = None
|
|
||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
version_code: Optional[int] = None
|
version_code: Optional[int] = None
|
||||||
download_url: Optional[str] = None
|
download_url: Optional[str] = None
|
||||||
file_size: Optional[int] = None
|
file_size: Optional[int] = None
|
||||||
release_notes: Optional[str] = None
|
release_notes: Optional[str] = None
|
||||||
|
min_system_version: Optional[str] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
is_latest: Optional[bool] = None
|
is_latest: Optional[bool] = None
|
||||||
min_system_version: Optional[str] = None
|
|
||||||
|
|
||||||
class ClientDownloadListResponse(BaseModel):
|
class ClientDownloadListResponse(BaseModel):
|
||||||
clients: List[ClientDownload]
|
clients: List[ClientDownload]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
# 声纹采集相关模型
|
# 声纹相关模型
|
||||||
class VoiceprintInfo(BaseModel):
|
class VoiceprintInfo(BaseModel):
|
||||||
vp_id: int
|
|
||||||
user_id: int
|
user_id: int
|
||||||
file_path: str
|
voiceprint_data: Any
|
||||||
file_size: Optional[int] = None
|
created_at: datetime.datetime
|
||||||
duration_seconds: Optional[float] = None
|
|
||||||
collected_at: datetime.datetime
|
|
||||||
updated_at: datetime.datetime
|
|
||||||
|
|
||||||
class VoiceprintStatus(BaseModel):
|
class VoiceprintStatus(BaseModel):
|
||||||
has_voiceprint: bool
|
has_voiceprint: bool
|
||||||
vp_id: Optional[int] = None
|
updated_at: Optional[datetime.datetime] = None
|
||||||
file_path: Optional[str] = None
|
|
||||||
duration_seconds: Optional[float] = None
|
|
||||||
collected_at: Optional[datetime.datetime] = None
|
|
||||||
|
|
||||||
class VoiceprintTemplate(BaseModel):
|
class VoiceprintTemplate(BaseModel):
|
||||||
template_text: str
|
content: str
|
||||||
duration_seconds: int
|
duration_seconds: int
|
||||||
sample_rate: int
|
sample_rate: int
|
||||||
channels: int
|
channels: int
|
||||||
|
|
@ -277,13 +291,51 @@ class RolePermissionInfo(BaseModel):
|
||||||
class UpdateRolePermissionsRequest(BaseModel):
|
class UpdateRolePermissionsRequest(BaseModel):
|
||||||
menu_ids: List[int]
|
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):
|
class Terminal(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
imei: str
|
imei: str
|
||||||
terminal_name: Optional[str] = None
|
terminal_name: Optional[str] = None
|
||||||
terminal_type: str
|
terminal_type: str
|
||||||
terminal_type_name: Optional[str] = None # 终端类型名称(从字典获取)
|
terminal_type_name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: int # 1: 启用, 0: 停用
|
status: int # 1: 启用, 0: 停用
|
||||||
is_activated: int # 1: 已激活, 0: 未激活
|
is_activated: int # 1: 已激活, 0: 未激活
|
||||||
|
|
@ -296,18 +348,23 @@ class Terminal(BaseModel):
|
||||||
updated_at: datetime.datetime
|
updated_at: datetime.datetime
|
||||||
created_by: Optional[int] = None
|
created_by: Optional[int] = None
|
||||||
creator_username: Optional[str] = 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):
|
class CreateTerminalRequest(BaseModel):
|
||||||
imei: str
|
imei: str
|
||||||
terminal_name: Optional[str] = None
|
terminal_name: Optional[str] = None
|
||||||
terminal_type: str
|
terminal_type: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
firmware_version: Optional[str] = None
|
||||||
|
mac_address: Optional[str] = None
|
||||||
status: int = 1
|
status: int = 1
|
||||||
|
|
||||||
class UpdateTerminalRequest(BaseModel):
|
class UpdateTerminalRequest(BaseModel):
|
||||||
terminal_name: Optional[str] = None
|
terminal_name: Optional[str] = None
|
||||||
terminal_type: Optional[str] = None
|
terminal_type: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
status: Optional[int] = None
|
|
||||||
firmware_version: Optional[str] = None
|
firmware_version: Optional[str] = None
|
||||||
mac_address: 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
|
import redis
|
||||||
from app.core.database import get_db_connection
|
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:
|
class AsyncKnowledgeBaseService:
|
||||||
"""异步知识库服务类 - 处理知识库相关的异步任务"""
|
"""异步知识库服务类 - 处理知识库相关的异步任务"""
|
||||||
|
|
@ -45,7 +45,7 @@ class AsyncKnowledgeBaseService:
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (task_id, user_id, kb_id, user_prompt, prompt_id))
|
cursor.execute(query, (task_id, user_id, kb_id, user_prompt, prompt_id))
|
||||||
else:
|
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)
|
self._save_task_to_db(task_id, user_id, kb_id, user_prompt, prompt_id)
|
||||||
|
|
||||||
current_time = datetime.now().isoformat()
|
current_time = datetime.now().isoformat()
|
||||||
|
|
@ -96,9 +96,7 @@ class AsyncKnowledgeBaseService:
|
||||||
|
|
||||||
# 4. 调用LLM API
|
# 4. 调用LLM API
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在生成知识库...")
|
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在生成知识库...")
|
||||||
generated_content = self.llm_service._call_llm_api(full_prompt)
|
generated_content = self.llm_service.call_llm_api_or_raise(full_prompt)
|
||||||
if not generated_content:
|
|
||||||
raise Exception("LLM API调用失败或返回空内容")
|
|
||||||
|
|
||||||
# 5. 保存结果到数据库
|
# 5. 保存结果到数据库
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存结果...")
|
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存结果...")
|
||||||
|
|
@ -110,6 +108,11 @@ class AsyncKnowledgeBaseService:
|
||||||
|
|
||||||
print(f"Task {task_id} completed successfully")
|
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:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"Task {task_id} failed: {error_msg}")
|
print(f"Task {task_id} failed: {error_msg}")
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,30 @@
|
||||||
"""
|
"""
|
||||||
异步会议服务 - 处理会议总结生成的异步任务
|
异步会议服务 - 处理会议总结生成的异步任务
|
||||||
采用FastAPI BackgroundTasks模式
|
采用受控线程池执行,避免阻塞 Web 请求进程
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import redis
|
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.core.database import get_db_connection
|
||||||
from app.services.async_transcription_service import AsyncTranscriptionService
|
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:
|
class AsyncMeetingService:
|
||||||
"""异步会议服务类 - 处理会议相关的异步任务"""
|
"""异步会议服务类 - 处理会议相关的异步任务"""
|
||||||
|
|
@ -23,14 +36,48 @@ class AsyncMeetingService:
|
||||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||||
self.llm_service = LLMService() # 复用现有的同步LLM服务
|
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:
|
Args:
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
user_prompt: 用户额外提示词
|
user_prompt: 用户额外提示词
|
||||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||||
|
model_code: 可选的LLM模型编码,如果不指定则使用默认模型
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 任务ID
|
str: 任务ID
|
||||||
|
|
@ -40,7 +87,7 @@ class AsyncMeetingService:
|
||||||
task_id = str(uuid.uuid4())
|
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,用于快速查询状态
|
# 将任务详情存入Redis,用于快速查询状态
|
||||||
current_time = datetime.now().isoformat()
|
current_time = datetime.now().isoformat()
|
||||||
|
|
@ -49,6 +96,7 @@ class AsyncMeetingService:
|
||||||
'meeting_id': str(meeting_id),
|
'meeting_id': str(meeting_id),
|
||||||
'user_prompt': user_prompt,
|
'user_prompt': user_prompt,
|
||||||
'prompt_id': str(prompt_id) if prompt_id else '',
|
'prompt_id': str(prompt_id) if prompt_id else '',
|
||||||
|
'model_code': model_code or '',
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'progress': '0',
|
'progress': '0',
|
||||||
'created_at': current_time,
|
'created_at': current_time,
|
||||||
|
|
@ -61,13 +109,15 @@ class AsyncMeetingService:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error starting summary generation: {e}")
|
print(f"Error starting summary generation: {e}")
|
||||||
raise e
|
raise
|
||||||
|
|
||||||
def _process_task(self, task_id: str):
|
def _process_task(self, task_id: str):
|
||||||
"""
|
"""
|
||||||
处理单个异步任务的函数,设计为由BackgroundTasks调用。
|
处理单个异步任务的函数,在线程池中执行。
|
||||||
"""
|
"""
|
||||||
print(f"Background task started for meeting summary task: {task_id}")
|
print(f"Background task started for meeting summary task: {task_id}")
|
||||||
|
lock_token = None
|
||||||
|
lock_key = f"lock:meeting-summary-task:{task_id}"
|
||||||
try:
|
try:
|
||||||
# 从Redis获取任务数据
|
# 从Redis获取任务数据
|
||||||
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
|
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
|
||||||
|
|
@ -79,6 +129,11 @@ class AsyncMeetingService:
|
||||||
user_prompt = task_data.get('user_prompt', '')
|
user_prompt = task_data.get('user_prompt', '')
|
||||||
prompt_id_str = task_data.get('prompt_id', '')
|
prompt_id_str = task_data.get('prompt_id', '')
|
||||||
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
|
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
|
# 1. 更新状态为processing
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
|
||||||
|
|
@ -89,33 +144,47 @@ class AsyncMeetingService:
|
||||||
if not transcript_text:
|
if not transcript_text:
|
||||||
raise Exception("无法获取会议转录内容")
|
raise Exception("无法获取会议转录内容")
|
||||||
|
|
||||||
# 3. 构建提示词
|
# 3. 构建消息
|
||||||
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
|
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正在分析会议内容...")
|
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
|
||||||
summary_content = self.llm_service._call_llm_api(full_prompt)
|
summary_content = self.llm_service.call_llm_api_messages_or_raise(messages, model_code=model_code)
|
||||||
if not summary_content:
|
|
||||||
raise Exception("LLM API调用失败或返回空内容")
|
|
||||||
|
|
||||||
# 5. 保存结果到主表
|
# 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)
|
self._save_summary_to_db(meeting_id, summary_content, user_prompt, prompt_id)
|
||||||
|
|
||||||
# 6. 任务完成
|
# 6. 导出MD文件到音频同目录
|
||||||
self._update_task_in_db(task_id, 'completed', 100, result=summary_content)
|
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
|
||||||
self._update_task_status_in_redis(task_id, 'completed', 100, result=summary_content)
|
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")
|
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:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
print(f"Task {task_id} failed: {error_msg}")
|
print(f"Task {task_id} failed: {error_msg}")
|
||||||
# 更新失败状态
|
self._mark_task_failed(task_id, error_msg)
|
||||||
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
|
finally:
|
||||||
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
|
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调用,在后台运行
|
此方法设计为由BackgroundTasks调用,在后台运行
|
||||||
|
|
@ -124,18 +193,24 @@ class AsyncMeetingService:
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
transcription_task_id: 转录任务ID
|
transcription_task_id: 转录任务ID
|
||||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||||
|
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||||
|
|
||||||
流程:
|
流程:
|
||||||
1. 循环轮询转录任务状态
|
1. 循环轮询转录任务状态
|
||||||
2. 转录成功后自动启动总结任务
|
2. 转录成功后自动启动总结任务
|
||||||
3. 转录失败或超时则停止轮询并记录日志
|
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']
|
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
|
||||||
max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time']
|
max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time']
|
||||||
max_polls = max_wait_time // poll_interval
|
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()
|
transcription_service = AsyncTranscriptionService()
|
||||||
|
|
@ -166,11 +241,16 @@ class AsyncMeetingService:
|
||||||
else:
|
else:
|
||||||
# 启动总结任务
|
# 启动总结任务
|
||||||
try:
|
try:
|
||||||
summary_task_id = self.start_summary_generation(meeting_id, user_prompt="", prompt_id=prompt_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}")
|
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}")
|
||||||
self._process_task(summary_task_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to start summary generation: {e}"
|
error_msg = f"Failed to start summary generation: {e}"
|
||||||
|
|
@ -207,9 +287,44 @@ class AsyncMeetingService:
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Monitor] Fatal error in monitor_and_auto_summarize: {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:
|
def _get_meeting_transcript(self, meeting_id: int) -> str:
|
||||||
"""从数据库获取会议转录内容"""
|
"""从数据库获取会议转录内容"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -241,35 +356,166 @@ class AsyncMeetingService:
|
||||||
print(f"获取会议转录内容错误: {e}")
|
print(f"获取会议转录内容错误: {e}")
|
||||||
return ""
|
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
|
||||||
使用数据库中配置的MEETING_TASK提示词模板
|
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 提示词模板作为任务级 system 指令
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
meeting_id: 会议ID
|
||||||
transcript_text: 会议转录文本
|
transcript_text: 会议转录文本
|
||||||
user_prompt: 用户额外提示词
|
user_prompt: 用户额外提示词
|
||||||
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版
|
||||||
"""
|
"""
|
||||||
# 从数据库获取会议任务的提示词模板(支持指定prompt_id)
|
task_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
|
||||||
system_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:
|
messages: List[Dict[str, str]] = []
|
||||||
prompt += f"用户额外要求:{user_prompt}\n\n"
|
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) -> int:
|
||||||
|
|
||||||
def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str, prompt_id: Optional[int] = None) -> Optional[int]:
|
|
||||||
"""保存总结到数据库 - 更新meetings表的summary、user_prompt、prompt_id和updated_at字段"""
|
"""保存总结到数据库 - 更新meetings表的summary、user_prompt、prompt_id和updated_at字段"""
|
||||||
try:
|
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor()
|
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_query = """
|
||||||
UPDATE meetings
|
UPDATE meetings
|
||||||
SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW()
|
SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW()
|
||||||
|
|
@ -281,10 +527,6 @@ class AsyncMeetingService:
|
||||||
print(f"成功保存会议总结到meetings表,meeting_id: {meeting_id}, prompt_id: {prompt_id}")
|
print(f"成功保存会议总结到meetings表,meeting_id: {meeting_id}, prompt_id: {prompt_id}")
|
||||||
return meeting_id
|
return meeting_id
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"保存总结到数据库错误: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# --- 状态查询和数据库操作方法 ---
|
# --- 状态查询和数据库操作方法 ---
|
||||||
|
|
||||||
def get_task_status(self, task_id: str) -> Dict[str, Any]:
|
def get_task_status(self, task_id: str) -> Dict[str, Any]:
|
||||||
|
|
@ -295,27 +537,63 @@ class AsyncMeetingService:
|
||||||
task_data = self._get_task_from_db(task_id)
|
task_data = self._get_task_from_db(task_id)
|
||||||
if not task_data:
|
if not task_data:
|
||||||
return {'task_id': task_id, 'status': 'not_found', 'error_message': 'Task not found'}
|
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 {
|
return {
|
||||||
'task_id': task_id,
|
'task_id': task_id,
|
||||||
'status': task_data.get('status', 'unknown'),
|
'status': task_data.get('status', 'unknown'),
|
||||||
'progress': int(task_data.get('progress', 0)),
|
'progress': int(task_data.get('progress', 0)),
|
||||||
'meeting_id': int(task_data.get('meeting_id', 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'),
|
'created_at': task_data.get('created_at'),
|
||||||
'updated_at': task_data.get('updated_at'),
|
'updated_at': task_data.get('updated_at'),
|
||||||
'result': task_data.get('result'),
|
'message': task_data.get('message'),
|
||||||
'error_message': task_data.get('error_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:
|
except Exception as e:
|
||||||
print(f"Error getting task status: {e}")
|
print(f"Error getting task status: {e}")
|
||||||
return {'task_id': task_id, 'status': 'error', 'error_message': str(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]]:
|
def get_meeting_llm_tasks(self, meeting_id: int) -> List[Dict[str, Any]]:
|
||||||
"""获取会议的所有LLM任务"""
|
"""获取会议的所有LLM任务"""
|
||||||
try:
|
try:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor(dictionary=True)
|
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,))
|
cursor.execute(query, (meeting_id,))
|
||||||
tasks = cursor.fetchall()
|
tasks = cursor.fetchall()
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
|
|
@ -342,7 +620,7 @@ class AsyncMeetingService:
|
||||||
|
|
||||||
# 查询最新的LLM任务
|
# 查询最新的LLM任务
|
||||||
query = """
|
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
|
FROM llm_tasks
|
||||||
WHERE meeting_id = %s
|
WHERE meeting_id = %s
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
|
|
@ -368,8 +646,10 @@ class AsyncMeetingService:
|
||||||
'status': task_record['status'],
|
'status': task_record['status'],
|
||||||
'progress': task_record['progress'] or 0,
|
'progress': task_record['progress'] or 0,
|
||||||
'meeting_id': meeting_id,
|
'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,
|
'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,
|
'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']
|
'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):
|
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中的任务状态"""
|
"""更新Redis中的任务状态"""
|
||||||
try:
|
try:
|
||||||
|
redis_key = f"llm_task:{task_id}"
|
||||||
update_data = {
|
update_data = {
|
||||||
'status': status,
|
'status': status,
|
||||||
'progress': str(progress),
|
'progress': str(progress),
|
||||||
'updated_at': datetime.now().isoformat()
|
'updated_at': datetime.now().isoformat()
|
||||||
}
|
}
|
||||||
if message: update_data['message'] = message
|
if message: update_data['message'] = message
|
||||||
if result: update_data['result'] = result
|
if result is not None: update_data['result'] = result
|
||||||
if error_message: update_data['error_message'] = error_message
|
if error_message is not None: update_data['error_message'] = error_message
|
||||||
self.redis_client.hset(f"llm_task:{task_id}", mapping=update_data)
|
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:
|
except Exception as e:
|
||||||
print(f"Error updating task status in Redis: {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:
|
Args:
|
||||||
|
|
@ -400,12 +692,16 @@ class AsyncMeetingService:
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
user_prompt: 用户额外提示词
|
user_prompt: 用户额外提示词
|
||||||
prompt_id: 可选的提示词模版ID,如果为None则使用默认模版
|
prompt_id: 可选的提示词模版ID,如果为None则使用默认模版
|
||||||
|
model_code: 可选的模型编码,用于恢复/重试时复用原始模型
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor()
|
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())"
|
insert_query = """
|
||||||
cursor.execute(insert_query, (task_id, meeting_id, user_prompt, prompt_id))
|
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()
|
connection.commit()
|
||||||
print(f"[Meeting Service] Task saved successfully to database")
|
print(f"[Meeting Service] Task saved successfully to database")
|
||||||
except Exception as e:
|
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):
|
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:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
params = [status, progress, error_message, task_id]
|
|
||||||
if status == 'completed':
|
if status == 'completed':
|
||||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s, result = %s, completed_at = NOW() WHERE task_id = %s"
|
query = """
|
||||||
params.insert(2, result)
|
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:
|
else:
|
||||||
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s WHERE task_id = %s"
|
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))
|
cursor.execute(query, params)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise RuntimeError(f"更新LLM任务状态失败,任务不存在: task_id={task_id}")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
except Exception as e:
|
|
||||||
print(f"Error updating task in database: {e}")
|
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]]:
|
def _get_task_from_db(self, task_id: str) -> Optional[Dict[str, str]]:
|
||||||
"""从数据库获取任务信息"""
|
"""从数据库获取任务信息"""
|
||||||
|
|
@ -438,13 +756,21 @@ class AsyncMeetingService:
|
||||||
cursor.execute(query, (task_id,))
|
cursor.execute(query, (task_id,))
|
||||||
task = cursor.fetchone()
|
task = cursor.fetchone()
|
||||||
if task:
|
if task:
|
||||||
# 确保所有字段都是字符串,以匹配Redis的行为
|
return {
|
||||||
return {k: v.isoformat() if isinstance(v, datetime) else str(v) for k, v in task.items()}
|
key: value.isoformat() if isinstance(value, datetime) else (None if value is None else str(value))
|
||||||
|
for key, value in task.items()
|
||||||
|
}
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error getting task from database: {e}")
|
print(f"Error getting task from database: {e}")
|
||||||
return None
|
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]:
|
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}")
|
print(f"Error checking existing summary task: {e}")
|
||||||
return None
|
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()
|
async_meeting_service = AsyncMeetingService()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import os
|
||||||
import redis
|
import redis
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -9,20 +9,153 @@ from http import HTTPStatus
|
||||||
import dashscope
|
import dashscope
|
||||||
from dashscope.audio.asr import Transcription
|
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.core.database import get_db_connection
|
||||||
from app.services.system_config_service import SystemConfigService
|
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:
|
class AsyncTranscriptionService:
|
||||||
"""异步转录服务类"""
|
"""异步转录服务类"""
|
||||||
|
|
||||||
def __init__(self):
|
PREPROCESS_COMPLETED_PROGRESS = 50
|
||||||
dashscope.api_key = QWEN_API_KEY
|
TRANSCRIPTION_COMPLETED_PROGRESS = 100
|
||||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
|
||||||
self.base_url = APP_CONFIG['base_url']
|
|
||||||
|
|
||||||
def start_transcription(self, meeting_id: int, audio_file_path: str) -> str:
|
def __init__(self):
|
||||||
|
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
||||||
|
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, business_task_id: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
启动异步转录任务
|
启动异步转录任务
|
||||||
|
|
||||||
|
|
@ -44,7 +177,13 @@ class AsyncTranscriptionService:
|
||||||
deleted_segments = cursor.rowcount
|
deleted_segments = cursor.rowcount
|
||||||
print(f"Deleted {deleted_segments} old transcript segments")
|
print(f"Deleted {deleted_segments} old transcript segments")
|
||||||
|
|
||||||
# 删除旧的转录任务记录
|
# 删除旧的转录任务记录;如果已创建本地占位任务,则保留当前任务记录
|
||||||
|
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,))
|
cursor.execute("DELETE FROM transcript_tasks WHERE meeting_id = %s", (meeting_id,))
|
||||||
deleted_tasks = cursor.rowcount
|
deleted_tasks = cursor.rowcount
|
||||||
print(f"Deleted {deleted_tasks} old transcript tasks")
|
print(f"Deleted {deleted_tasks} old transcript tasks")
|
||||||
|
|
@ -57,61 +196,118 @@ class AsyncTranscriptionService:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
# 2. 构造完整的文件URL
|
# 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
|
# 3. 调用Paraformer异步API
|
||||||
call_params = {
|
task_response = self._dashscope_async_call(request_options, call_params, timeout_seconds)
|
||||||
'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)
|
|
||||||
|
|
||||||
if task_response.status_code != HTTPStatus.OK:
|
if task_response.status_code != HTTPStatus.OK:
|
||||||
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
|
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
|
||||||
raise Exception(f"Transcription API error: {task_response.message}")
|
raise Exception(f"Transcription API error: {task_response.message}")
|
||||||
|
|
||||||
paraformer_task_id = task_response.output.task_id
|
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中存储任务映射
|
# 4. 在Redis中存储任务映射
|
||||||
current_time = datetime.now().isoformat()
|
current_time = datetime.now().isoformat()
|
||||||
task_data = {
|
task_data = {
|
||||||
'business_task_id': business_task_id,
|
'business_task_id': final_business_task_id,
|
||||||
'paraformer_task_id': paraformer_task_id,
|
'paraformer_task_id': paraformer_task_id,
|
||||||
'meeting_id': str(meeting_id),
|
'meeting_id': str(meeting_id),
|
||||||
'file_url': file_url,
|
'file_url': file_url,
|
||||||
'status': 'pending',
|
'status': 'pending',
|
||||||
'progress': '0',
|
'progress': str(self.PREPROCESS_COMPLETED_PROGRESS),
|
||||||
'created_at': current_time,
|
'created_at': current_time,
|
||||||
'updated_at': current_time
|
'updated_at': current_time
|
||||||
}
|
}
|
||||||
|
|
||||||
# 存储到Redis,过期时间24小时
|
# 存储到Redis,过期时间24小时
|
||||||
self.redis_client.hset(f"task:{business_task_id}", mapping=task_data)
|
self.redis_client.hset(f"task:{final_business_task_id}", mapping=task_data)
|
||||||
self.redis_client.expire(f"task:{business_task_id}", 86400)
|
self.redis_client.expire(f"task:{final_business_task_id}", 86400)
|
||||||
|
|
||||||
# 5. 在数据库中创建任务记录
|
# 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}")
|
print(f"Transcription task created: {final_business_task_id}")
|
||||||
return business_task_id
|
return final_business_task_id
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error starting transcription: {e}")
|
print(f"Error starting transcription: {e}")
|
||||||
raise 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]:
|
def get_task_status(self, business_task_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
获取任务状态
|
获取任务状态
|
||||||
|
|
@ -126,37 +322,69 @@ class AsyncTranscriptionService:
|
||||||
current_status = 'failed'
|
current_status = 'failed'
|
||||||
progress = 0
|
progress = 0
|
||||||
error_message = "An unknown error occurred."
|
error_message = "An unknown error occurred."
|
||||||
|
updated_at = datetime.now().isoformat()
|
||||||
|
status_cache_key = f"task_status_cache:{business_task_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. 获取任务数据(优先Redis,回源DB)
|
# 1. 获取任务数据(优先Redis,回源DB)
|
||||||
task_data = self._get_task_data(business_task_id)
|
task_data = self._get_task_data(business_task_id)
|
||||||
paraformer_task_id = task_data['paraformer_task_id']
|
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:
|
||||||
|
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获取状态
|
# 2. 查询外部API获取状态
|
||||||
|
paraformer_response = None
|
||||||
try:
|
try:
|
||||||
paraformer_response = Transcription.fetch(task=paraformer_task_id)
|
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:
|
if paraformer_response.status_code != HTTPStatus.OK:
|
||||||
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
|
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
|
||||||
|
|
||||||
paraformer_status = paraformer_response.output.task_status
|
paraformer_status = paraformer_response.output.task_status
|
||||||
current_status = self._map_paraformer_status(paraformer_status)
|
current_status = self._map_paraformer_status(paraformer_status)
|
||||||
progress = self._calculate_progress(paraformer_status)
|
progress = self._calculate_progress(paraformer_status)
|
||||||
error_message = None #执行成功,清除初始状态
|
error_message = None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_status = 'failed'
|
# 云侧状态查询抖动不应直接把任务打成 failed,
|
||||||
progress = 0
|
# 保持当前非终态并等待下一轮轮询重试。
|
||||||
error_message = f"Error fetching status from provider: {e}"
|
current_status = task_data.get('status') or 'processing'
|
||||||
# 直接进入finally块更新状态后返回
|
progress = int(task_data.get('progress') or 0)
|
||||||
return
|
error_message = None
|
||||||
|
print(
|
||||||
|
f"Transient provider status fetch error for task {business_task_id}: {e}. "
|
||||||
|
f"Keeping status={current_status}, progress={progress}"
|
||||||
|
)
|
||||||
|
|
||||||
# 3. 如果任务完成,处理结果
|
# 3. 如果任务完成,处理结果
|
||||||
if current_status == 'completed' and paraformer_response.output.get('results'):
|
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)
|
db_task_status = self._get_task_status_from_db(business_task_id)
|
||||||
if db_task_status != 'completed':
|
if db_task_status != 'completed':
|
||||||
# 只有当数据库中状态不是completed时才处理
|
|
||||||
# 先将状态更新为completed,作为分布式锁
|
|
||||||
self._update_task_status_in_db(business_task_id, 'completed', 100, None)
|
self._update_task_status_in_db(business_task_id, 'completed', 100, None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -167,7 +395,7 @@ class AsyncTranscriptionService:
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_status = 'failed'
|
current_status = 'failed'
|
||||||
progress = 100 # 进度为100,但状态是失败
|
progress = 100
|
||||||
error_message = f"Error processing transcription result: {e}"
|
error_message = f"Error processing transcription result: {e}"
|
||||||
print(error_message)
|
print(error_message)
|
||||||
else:
|
else:
|
||||||
|
|
@ -192,37 +420,44 @@ class AsyncTranscriptionService:
|
||||||
if error_message:
|
if error_message:
|
||||||
update_data['error_message'] = error_message
|
update_data['error_message'] = error_message
|
||||||
self.redis_client.hset(f"task:{business_task_id}", mapping=update_data)
|
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)
|
self._update_task_status_in_db(business_task_id, current_status, progress, error_message)
|
||||||
|
|
||||||
# 5. 构造并返回最终结果
|
# 5. 构造并返回最终结果
|
||||||
result = {
|
return self._build_task_status_result(
|
||||||
'task_id': business_task_id,
|
business_task_id,
|
||||||
'status': current_status,
|
task_data,
|
||||||
'progress': progress,
|
current_status,
|
||||||
'error_message': error_message,
|
progress,
|
||||||
'updated_at': updated_at,
|
error_message,
|
||||||
'meeting_id': None,
|
updated_at,
|
||||||
'created_at': None,
|
)
|
||||||
}
|
|
||||||
if task_data:
|
|
||||||
result['meeting_id'] = int(task_data['meeting_id'])
|
|
||||||
result['created_at'] = task_data.get('created_at')
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _get_task_data(self, business_task_id: str) -> Dict[str, Any]:
|
def _get_task_data(self, business_task_id: str) -> Dict[str, Any]:
|
||||||
"""从Redis或数据库获取任务数据"""
|
"""从Redis或数据库获取任务数据"""
|
||||||
# 尝试从Redis获取
|
# 尝试从Redis获取
|
||||||
task_data_bytes = self.redis_client.hgetall(f"task:{business_task_id}")
|
task_data_raw = self.redis_client.hgetall(f"task:{business_task_id}")
|
||||||
if task_data_bytes and task_data_bytes.get(b'paraformer_task_id'):
|
if task_data_raw:
|
||||||
# Redis返回的是bytes,需要解码
|
task_data = self._normalize_redis_mapping(task_data_raw)
|
||||||
return {k.decode('utf-8'): v.decode('utf-8') for k, v in task_data_bytes.items()}
|
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没有,从数据库回源
|
# 如果Redis没有,从数据库回源
|
||||||
task_data_from_db = self._get_task_from_db(business_task_id)
|
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")
|
raise Exception("Task not found in DB or paraformer_task_id is missing")
|
||||||
|
|
||||||
# 将从DB获取的数据缓存回Redis
|
# 将从DB获取的数据缓存回Redis
|
||||||
|
|
@ -296,9 +531,10 @@ class AsyncTranscriptionService:
|
||||||
def _calculate_progress(self, paraformer_status: str) -> int:
|
def _calculate_progress(self, paraformer_status: str) -> int:
|
||||||
"""根据Paraformer状态计算进度"""
|
"""根据Paraformer状态计算进度"""
|
||||||
progress_mapping = {
|
progress_mapping = {
|
||||||
'PENDING': 10,
|
# 预处理完成后,转录任务进入云侧排队/执行阶段,统一展示为 50。
|
||||||
'RUNNING': 50,
|
'PENDING': self.PREPROCESS_COMPLETED_PROGRESS,
|
||||||
'SUCCEEDED': 100,
|
'RUNNING': self.PREPROCESS_COMPLETED_PROGRESS,
|
||||||
|
'SUCCEEDED': self.TRANSCRIPTION_COMPLETED_PROGRESS,
|
||||||
'FAILED': 0
|
'FAILED': 0
|
||||||
}
|
}
|
||||||
return progress_mapping.get(paraformer_status, 0)
|
return progress_mapping.get(paraformer_status, 0)
|
||||||
|
|
@ -309,12 +545,37 @@ class AsyncTranscriptionService:
|
||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
|
|
||||||
# 插入转录任务记录
|
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_task_query = """
|
||||||
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at)
|
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at)
|
||||||
VALUES (%s, %s, %s, 'pending', 0, NOW())
|
VALUES (%s, %s, %s, 'pending', %s, NOW())
|
||||||
"""
|
"""
|
||||||
cursor.execute(insert_task_query, (business_task_id, paraformer_task_id, meeting_id))
|
cursor.execute(
|
||||||
|
insert_task_query,
|
||||||
|
(
|
||||||
|
business_task_id,
|
||||||
|
paraformer_task_id,
|
||||||
|
meeting_id,
|
||||||
|
self.PREPROCESS_COMPLETED_PROGRESS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
@ -376,7 +637,8 @@ class AsyncTranscriptionService:
|
||||||
cursor = connection.cursor(dictionary=True)
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
query = """
|
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
|
FROM transcript_tasks tt
|
||||||
WHERE tt.task_id = %s
|
WHERE tt.task_id = %s
|
||||||
"""
|
"""
|
||||||
|
|
@ -391,7 +653,14 @@ class AsyncTranscriptionService:
|
||||||
'paraformer_task_id': result['paraformer_task_id'],
|
'paraformer_task_id': result['paraformer_task_id'],
|
||||||
'meeting_id': str(result['meeting_id']),
|
'meeting_id': str(result['meeting_id']),
|
||||||
'status': result['status'],
|
'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
|
return None
|
||||||
|
|
||||||
|
|
@ -399,6 +668,41 @@ class AsyncTranscriptionService:
|
||||||
print(f"Error getting task from database: {e}")
|
print(f"Error getting task from database: {e}")
|
||||||
return None
|
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):
|
def _process_transcription_result(self, business_task_id: str, meeting_id: int, paraformer_output: Any):
|
||||||
"""
|
"""
|
||||||
处理转录结果.
|
处理转录结果.
|
||||||
|
|
@ -411,7 +715,13 @@ class AsyncTranscriptionService:
|
||||||
transcription_url = paraformer_output['results'][0]['transcription_url']
|
transcription_url = paraformer_output['results'][0]['transcription_url']
|
||||||
print(f"Fetching transcription from URL: {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()
|
response.raise_for_status()
|
||||||
transcription_data = response.json()
|
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,
|
auto_summarize: bool = True,
|
||||||
background_tasks: BackgroundTasks = None,
|
background_tasks: BackgroundTasks = None,
|
||||||
prompt_id: int = None,
|
prompt_id: int = None,
|
||||||
duration: int = 0
|
model_code: str = None,
|
||||||
|
duration: int = 0,
|
||||||
|
transcription_task_id: str = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
处理已保存的完整音频文件
|
处理已保存的完整音频文件
|
||||||
|
|
@ -44,9 +46,11 @@ def handle_audio_upload(
|
||||||
meeting_id: 会议ID
|
meeting_id: 会议ID
|
||||||
current_user: 当前用户信息
|
current_user: 当前用户信息
|
||||||
auto_summarize: 是否自动生成总结(默认True)
|
auto_summarize: 是否自动生成总结(默认True)
|
||||||
background_tasks: FastAPI 后台任务对象
|
background_tasks: 为兼容现有调用保留,实际后台执行由服务层线程池完成
|
||||||
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版)
|
||||||
|
model_code: 总结模型编码(可选,如果不指定则使用默认模型)
|
||||||
duration: 音频时长(秒)
|
duration: 音频时长(秒)
|
||||||
|
transcription_task_id: 预先创建的本地任务ID(可选,用于异步上传场景)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {
|
dict: {
|
||||||
|
|
@ -58,7 +62,7 @@ def handle_audio_upload(
|
||||||
"has_transcription": bool # 原来是否有转录记录 (成功时)
|
"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. 权限和已有文件检查
|
# 1. 权限和已有文件检查
|
||||||
try:
|
try:
|
||||||
|
|
@ -136,18 +140,22 @@ def handle_audio_upload(
|
||||||
|
|
||||||
# 4. 启动转录任务
|
# 4. 启动转录任务
|
||||||
try:
|
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}")
|
print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}")
|
||||||
|
|
||||||
# 5. 如果启用自动总结且提供了 background_tasks,添加监控任务
|
# 5. 如果启用自动总结,则提交后台监控任务
|
||||||
if auto_summarize and transcription_task_id and background_tasks:
|
if auto_summarize and transcription_task_id:
|
||||||
background_tasks.add_task(
|
async_meeting_service.enqueue_transcription_monitor(
|
||||||
async_meeting_service.monitor_and_auto_summarize,
|
|
||||||
meeting_id,
|
meeting_id,
|
||||||
transcription_task_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:
|
except Exception as e:
|
||||||
print(f"Failed to start transcription: {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 datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from app.core.config import REDIS_CONFIG
|
from app.core.config import REDIS_CONFIG
|
||||||
|
from app.services.system_config_service import SystemConfigService
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# JWT配置
|
# JWT配置
|
||||||
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-super-secret-key-change-in-production')
|
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-super-secret-key-change-in-production')
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7天
|
|
||||||
|
|
||||||
class JWTService:
|
class JWTService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.redis_client = redis.Redis(**REDIS_CONFIG)
|
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:
|
def create_access_token(self, data: Dict[str, Any]) -> str:
|
||||||
"""创建JWT访问令牌"""
|
"""创建JWT访问令牌"""
|
||||||
to_encode = data.copy()
|
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"})
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
|
|
||||||
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
||||||
|
|
@ -27,7 +33,7 @@ class JWTService:
|
||||||
if user_id:
|
if user_id:
|
||||||
self.redis_client.setex(
|
self.redis_client.setex(
|
||||||
f"token:{user_id}:{encoded_jwt}",
|
f"token:{user_id}:{encoded_jwt}",
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Redis需要秒
|
expire_minutes * 60, # Redis需要秒
|
||||||
"active"
|
"active"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,312 @@
|
||||||
import json
|
import json
|
||||||
import dashscope
|
import os
|
||||||
from http import HTTPStatus
|
from typing import Optional, Dict, Generator, Any, List
|
||||||
from typing import Optional, Dict, List, Generator, Any
|
|
||||||
import app.core.config as config_module
|
import httpx
|
||||||
|
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
from app.services.system_config_service import SystemConfigService
|
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:
|
class LLMService:
|
||||||
"""LLM服务 - 专注于大模型API调用和提示词管理"""
|
"""LLM服务 - 专注于大模型API调用和提示词管理"""
|
||||||
|
|
||||||
def __init__(self):
|
@staticmethod
|
||||||
# 设置dashscope API key
|
def _use_system_proxy() -> bool:
|
||||||
dashscope.api_key = config_module.QWEN_API_KEY
|
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]:
|
def _get_llm_call_params(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
获取 dashscope.Generation.call() 所需的参数字典
|
获取 OpenAI 兼容接口调用参数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict: 包含 model、timeout、temperature、top_p 的参数字典
|
Dict: 包含 endpoint_url、api_key、model、timeout、temperature、top_p、max_tokens 的参数字典
|
||||||
"""
|
"""
|
||||||
return {
|
return self.build_call_params_from_config()
|
||||||
'model': SystemConfigService.get_llm_model_name(),
|
|
||||||
'timeout': SystemConfigService.get_llm_timeout(),
|
@staticmethod
|
||||||
'temperature': SystemConfigService.get_llm_temperature(),
|
def _build_chat_url(endpoint_url: str) -> str:
|
||||||
'top_p': SystemConfigService.get_llm_top_p(),
|
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:
|
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,如果指定则使用该提示词,否则使用默认提示词
|
prompt_id: 可选的提示词ID,如果指定则使用该提示词,否则使用默认提示词
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 提示词内容,如果未找到返回默认提示词
|
str: 提示词内容
|
||||||
"""
|
"""
|
||||||
# 如果指定了 prompt_id,直接获取该提示词
|
# 如果指定了 prompt_id,直接获取该提示词
|
||||||
if prompt_id:
|
if prompt_id:
|
||||||
|
|
@ -74,63 +354,191 @@ class LLMService:
|
||||||
if result:
|
if result:
|
||||||
return result['content']
|
return result['content']
|
||||||
|
|
||||||
# 返回默认提示词
|
prompt_label = f"ID={prompt_id}" if prompt_id else f"task_type={task_type} 的默认模版"
|
||||||
return self._get_default_prompt(task_type)
|
raise LLMServiceError(f"未找到可用提示词模版:{prompt_label}")
|
||||||
|
|
||||||
def _get_default_prompt(self, task_name: str) -> str:
|
def stream_llm_api(
|
||||||
"""获取默认提示词"""
|
self,
|
||||||
system_prompt = config_module.LLM_CONFIG.get("system_prompt", "请根据提供的内容进行总结和分析。")
|
prompt: Optional[str] = None,
|
||||||
default_prompts = {
|
model_code: Optional[str] = None,
|
||||||
'MEETING_TASK': system_prompt,
|
config: Optional[Dict[str, Any]] = None,
|
||||||
'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。",
|
messages: Optional[List[Dict[str, Any]]] = None,
|
||||||
}
|
) -> Generator[str, None, None]:
|
||||||
return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。")
|
"""流式调用 OpenAI 兼容大模型API。"""
|
||||||
|
|
||||||
def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]:
|
|
||||||
"""流式调用阿里Qwen大模型API"""
|
|
||||||
try:
|
try:
|
||||||
responses = dashscope.Generation.call(
|
params = self._resolve_call_params(model_code=model_code, config=config)
|
||||||
**self._get_llm_call_params(),
|
validation_error = self._validate_call_params(params)
|
||||||
prompt=prompt,
|
if validation_error:
|
||||||
stream=True,
|
yield f"error: {validation_error}"
|
||||||
incremental_output=True
|
return
|
||||||
)
|
|
||||||
|
|
||||||
for response in responses:
|
timeout = self._build_timeout(params["timeout"])
|
||||||
if response.status_code == HTTPStatus.OK:
|
with self._create_httpx_client() as client:
|
||||||
# 增量输出内容
|
with client.stream(
|
||||||
new_content = response.output.get('text', '')
|
"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:
|
if new_content:
|
||||||
yield new_content
|
yield new_content
|
||||||
else:
|
except LLMServiceError as e:
|
||||||
error_msg = f"Request failed with status code: {response.status_code}, Error: {response.message}"
|
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)
|
print(error_msg)
|
||||||
yield f"error: {error_msg}"
|
yield f"error: {error_msg}"
|
||||||
break
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"流式调用大模型API错误: {e}"
|
error_msg = f"流式调用大模型API错误: {e}"
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
yield f"error: {error_msg}"
|
yield f"error: {error_msg}"
|
||||||
|
|
||||||
def _call_llm_api(self, prompt: str) -> Optional[str]:
|
def call_llm_api(
|
||||||
"""调用阿里Qwen大模型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:
|
try:
|
||||||
response = dashscope.Generation.call(
|
return self.call_llm_api_or_raise(
|
||||||
**self._get_llm_call_params(),
|
prompt=prompt,
|
||||||
prompt=prompt
|
model_code=model_code,
|
||||||
|
config=config,
|
||||||
|
messages=messages,
|
||||||
)
|
)
|
||||||
|
except LLMServiceError as e:
|
||||||
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:
|
|
||||||
print(f"调用大模型API错误: {e}")
|
print(f"调用大模型API错误: {e}")
|
||||||
return None
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,18 +1,32 @@
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
from threading import RLock
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from app.core.database import get_db_connection
|
from app.core.database import get_db_connection
|
||||||
|
|
||||||
|
|
||||||
class SystemConfigService:
|
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'
|
ASR_VOCABULARY_ID = 'asr_vocabulary_id'
|
||||||
TIMELINE_PAGESIZE = 'timeline_pagesize'
|
PAGE_SIZE = 'page_size'
|
||||||
DEFAULT_RESET_PASSWORD = 'default_reset_password'
|
DEFAULT_RESET_PASSWORD = 'default_reset_password'
|
||||||
MAX_AUDIO_SIZE = 'max_audio_size'
|
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'
|
VOICEPRINT_TEMPLATE_TEXT = 'voiceprint_template_text'
|
||||||
|
|
@ -26,6 +40,273 @@ class SystemConfigService:
|
||||||
LLM_TIMEOUT = 'llm_timeout'
|
LLM_TIMEOUT = 'llm_timeout'
|
||||||
LLM_TEMPERATURE = 'llm_temperature'
|
LLM_TEMPERATURE = 'llm_temperature'
|
||||||
LLM_TOP_P = 'llm_top_p'
|
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
|
@classmethod
|
||||||
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
|
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
|
||||||
|
|
@ -39,30 +320,17 @@ class SystemConfigService:
|
||||||
Returns:
|
Returns:
|
||||||
配置项的值
|
配置项的值
|
||||||
"""
|
"""
|
||||||
try:
|
cached_value = cls._get_cached_config(dict_code)
|
||||||
with get_db_connection() as conn:
|
if cached_value is not None:
|
||||||
cursor = conn.cursor(dictionary=True)
|
return cached_value
|
||||||
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()
|
|
||||||
|
|
||||||
if result and result['extension_attr']:
|
# 1) 新参数表
|
||||||
try:
|
value = cls._get_parameter_value(dict_code)
|
||||||
ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr']
|
if value is not None:
|
||||||
return ext_attr.get('value', default_value)
|
cls._set_cached_config(dict_code, value)
|
||||||
except (json.JSONDecodeError, AttributeError):
|
return value
|
||||||
pass
|
|
||||||
|
|
||||||
return default_value
|
cls._set_cached_config(dict_code, default_value)
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error getting config {dict_code}: {e}")
|
|
||||||
return default_value
|
return default_value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -80,31 +348,17 @@ class SystemConfigService:
|
||||||
Returns:
|
Returns:
|
||||||
属性值
|
属性值
|
||||||
"""
|
"""
|
||||||
try:
|
# 1) 新模型配置表
|
||||||
with get_db_connection() as conn:
|
model_json = cls._get_model_config_json(dict_code)
|
||||||
cursor = conn.cursor(dictionary=True)
|
if model_json is not None:
|
||||||
query = """
|
return model_json.get(attr_name, default_value)
|
||||||
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()
|
|
||||||
|
|
||||||
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:
|
@classmethod
|
||||||
print(f"Error getting config attribute {dict_code}.{attr_name}: {e}")
|
def get_model_runtime_config(cls, model_code: str) -> Optional[Dict[str, Any]]:
|
||||||
return default_value
|
"""获取模型运行时配置,优先从新模型配置表读取。"""
|
||||||
|
return cls._get_model_config_json(model_code)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_config(cls, dict_code: str, value: Any, label_cn: str = None) -> bool:
|
def set_config(cls, dict_code: str, value: Any, label_cn: str = None) -> bool:
|
||||||
|
|
@ -119,46 +373,69 @@ class SystemConfigService:
|
||||||
Returns:
|
Returns:
|
||||||
是否设置成功
|
是否设置成功
|
||||||
"""
|
"""
|
||||||
|
# 1) 优先写入新参数表
|
||||||
try:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
# 检查配置是否存在
|
|
||||||
cursor.execute(
|
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()
|
if dict_code == cls.ASR_VOCABULARY_ID:
|
||||||
|
cursor.execute(
|
||||||
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))
|
INSERT INTO audio_model_config
|
||||||
else:
|
(model_code, model_name, audio_scene, provider, request_timeout_seconds, extra_config, description, is_active, is_default)
|
||||||
# 插入新配置
|
VALUES (
|
||||||
if not label_cn:
|
'audio_model',
|
||||||
label_cn = dict_code
|
'音频识别模型',
|
||||||
|
'asr',
|
||||||
insert_query = """
|
'dashscope',
|
||||||
INSERT INTO dict_data (
|
300,
|
||||||
dict_type, dict_code, parent_code, label_cn,
|
JSON_OBJECT(
|
||||||
extension_attr, status, sort_order
|
'model', 'paraformer-v2',
|
||||||
) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0)
|
'vocabulary_id', %s,
|
||||||
"""
|
'speaker_count', 10,
|
||||||
cursor.execute(insert_query, (cls.DICT_TYPE, dict_code, label_cn, extension_attr))
|
'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()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
cls.invalidate_cache()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error setting config {dict_code}: {e}")
|
print(f"Error setting config in sys_system_parameters {dict_code}: {e}")
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -169,34 +446,64 @@ class SystemConfigService:
|
||||||
Returns:
|
Returns:
|
||||||
配置字典 {dict_code: value}
|
配置字典 {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:
|
try:
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
query = """
|
cursor.execute(
|
||||||
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,))
|
SELECT param_key, param_value
|
||||||
results = cursor.fetchall()
|
FROM sys_system_parameters
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY category, param_key
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
if rows:
|
||||||
configs = {}
|
configs = {row["param_key"]: row["param_value"] for row in rows}
|
||||||
for row in results:
|
with cls._cache_lock:
|
||||||
if row['extension_attr']:
|
cls._all_configs_cache = (time.time(), configs)
|
||||||
try:
|
return dict(configs)
|
||||||
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
|
|
||||||
|
|
||||||
except Exception as e:
|
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 {}
|
return {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -216,22 +523,55 @@ class SystemConfigService:
|
||||||
success = False
|
success = False
|
||||||
return success
|
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
|
@classmethod
|
||||||
def get_asr_vocabulary_id(cls) -> Optional[str]:
|
def get_asr_vocabulary_id(cls) -> Optional[str]:
|
||||||
"""获取ASR热词字典ID"""
|
"""获取ASR热词字典ID — 优先从 audio_model_config.hot_word_group_id → hot_word_group.vocabulary_id"""
|
||||||
return cls.get_config(cls.ASR_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
|
@classmethod
|
||||||
def get_voiceprint_template(cls, default: str = "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。") -> str:
|
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
|
@classmethod
|
||||||
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
|
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
|
||||||
"""获取声纹文件大小限制 (bytes), 默认5MB"""
|
"""获取声纹文件大小限制 (bytes), 默认5MB"""
|
||||||
value = cls.get_config_attribute('voiceprint', 'voiceprint_max_size', default)
|
value = cls.get_config_attribute('voiceprint_model', 'max_size_bytes', default)
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -240,7 +580,7 @@ class SystemConfigService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_voiceprint_duration(cls, default: int = 12) -> int:
|
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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -249,7 +589,7 @@ class SystemConfigService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_voiceprint_sample_rate(cls, default: int = 16000) -> int:
|
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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -258,34 +598,98 @@ class SystemConfigService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_voiceprint_channels(cls, default: int = 1) -> int:
|
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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_timeline_pagesize(cls, default: int = 10) -> int:
|
def get_page_size(cls) -> int:
|
||||||
"""获取会议时间轴每页数量"""
|
"""获取系统通用分页数量。"""
|
||||||
value = cls.get_config(cls.TIMELINE_PAGESIZE, str(default))
|
value = cls.get_config(cls.PAGE_SIZE)
|
||||||
|
if value is None:
|
||||||
|
raise RuntimeError("系统参数 page_size 缺失")
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
raise RuntimeError(f"系统参数 page_size 非法: {value!r}") from None
|
||||||
|
|
||||||
@classmethod
|
@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
|
@classmethod
|
||||||
def get_max_audio_size(cls, default: int = 100) -> int:
|
def get_max_audio_size(cls) -> int:
|
||||||
"""获取上传音频文件大小限制(MB)"""
|
"""获取上传音频文件大小限制(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:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (ValueError, TypeError):
|
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模型配置获取方法(直接使用通用方法)
|
# LLM模型配置获取方法(直接使用通用方法)
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -319,3 +723,33 @@ class SystemConfigService:
|
||||||
return float(value)
|
return float(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return default
|
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,
|
cu.caption as current_user_caption,
|
||||||
dd.label_cn as terminal_type_name
|
dd.label_cn as terminal_type_name
|
||||||
FROM terminals t
|
FROM terminals t
|
||||||
LEFT JOIN users u ON t.created_by = u.user_id
|
LEFT JOIN sys_users u ON t.created_by = u.user_id
|
||||||
LEFT JOIN users cu ON t.current_user_id = cu.user_id
|
LEFT JOIN sys_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_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
|
|
@ -75,8 +75,8 @@ class TerminalService:
|
||||||
u.username as creator_username,
|
u.username as creator_username,
|
||||||
dd.label_cn as terminal_type_name
|
dd.label_cn as terminal_type_name
|
||||||
FROM terminals t
|
FROM terminals t
|
||||||
LEFT JOIN users u ON t.created_by = u.user_id
|
LEFT JOIN sys_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_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
|
||||||
WHERE t.id = %s
|
WHERE t.id = %s
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (terminal_id,))
|
cursor.execute(query, (terminal_id,))
|
||||||
|
|
@ -105,14 +105,17 @@ class TerminalService:
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO terminals (
|
INSERT INTO terminals (
|
||||||
imei, terminal_name, terminal_type, description, status, created_by
|
imei, terminal_name, terminal_type, description,
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s)
|
firmware_version, mac_address, status, created_by
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (
|
cursor.execute(query, (
|
||||||
terminal_data.imei,
|
terminal_data.imei,
|
||||||
terminal_data.terminal_name,
|
terminal_data.terminal_name,
|
||||||
terminal_data.terminal_type,
|
terminal_data.terminal_type,
|
||||||
terminal_data.description,
|
terminal_data.description,
|
||||||
|
terminal_data.firmware_version,
|
||||||
|
terminal_data.mac_address,
|
||||||
terminal_data.status,
|
terminal_data.status,
|
||||||
user_id
|
user_id
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,16 @@
|
||||||
|
|
||||||
用于解析音频文件的元数据信息,如时长、采样率、编码格式等
|
用于解析音频文件的元数据信息,如时长、采样率、编码格式等
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
from tinytag import TinyTag
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
def get_audio_duration(file_path: str) -> int:
|
def get_audio_duration(file_path: str) -> int:
|
||||||
"""
|
"""
|
||||||
获取音频文件时长(秒)
|
获取音频文件时长(秒)
|
||||||
|
|
||||||
使用TinyTag读取音频文件时长
|
使用 ffprobe 读取音频时长
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: 音频文件的完整路径
|
file_path: 音频文件的完整路径
|
||||||
|
|
@ -26,13 +27,33 @@ def get_audio_duration(file_path: str) -> int:
|
||||||
- WAV (.wav)
|
- WAV (.wav)
|
||||||
- OGG (.ogg)
|
- OGG (.ogg)
|
||||||
- FLAC (.flac)
|
- FLAC (.flac)
|
||||||
- 以及TinyTag支持的其他音频格式
|
- 以及 ffprobe 支持的其他音频格式
|
||||||
"""
|
"""
|
||||||
|
ffprobe_path = shutil.which("ffprobe")
|
||||||
|
if not ffprobe_path:
|
||||||
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tag = TinyTag.get(file_path)
|
completed = subprocess.run(
|
||||||
if tag.duration and tag.duration > 0:
|
[
|
||||||
return int(tag.duration)
|
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:
|
except Exception as e:
|
||||||
print(f"获取音频时长失败 ({file_path}): {e}")
|
print(f"ffprobe 获取音频时长失败 ({file_path}): {e}")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Core Application Framework
|
# Core Application Framework
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn
|
||||||
|
mcp
|
||||||
|
|
||||||
# Database & Cache
|
# Database & Cache
|
||||||
mysql-connector-python
|
mysql-connector-python
|
||||||
|
|
@ -8,6 +9,7 @@ redis
|
||||||
|
|
||||||
# Services & External APIs
|
# Services & External APIs
|
||||||
requests
|
requests
|
||||||
|
httpx
|
||||||
dashscope
|
dashscope
|
||||||
PyJWT
|
PyJWT
|
||||||
qiniu
|
qiniu
|
||||||
|
|
@ -22,6 +24,4 @@ psutil
|
||||||
# APK Parsing
|
# APK Parsing
|
||||||
pyaxmlparser
|
pyaxmlparser
|
||||||
|
|
||||||
# Audio Metadata
|
|
||||||
tinytag
|
|
||||||
python-dotenv
|
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) |
|
| `task_id` | VARCHAR(100) | PRIMARY KEY | 业务任务唯一ID (UUID) |
|
||||||
| `meeting_id` | INT | NOT NULL, FK | 关联的会议ID |
|
| `meeting_id` | INT | NOT NULL, FK | 关联的会议ID |
|
||||||
| `prompt_id` | INT | NOT NULL, FK | 关联的提示词模版ID |
|
| `prompt_id` | INT | NOT NULL, FK | 关联的提示词模版ID |
|
||||||
|
| `model_code` | VARCHAR(100) | NULL | 本次任务使用的模型编码,用于服务恢复和任务重试时复用原模型 |
|
||||||
| `user_prompt` | TEXT | NULL | 用户输入的额外提示 |
|
| `user_prompt` | TEXT | NULL | 用户输入的额外提示 |
|
||||||
| `status` | ENUM(...) | DEFAULT 'pending' | 任务状态: 'pending', 'processing', 'completed', 'failed' |
|
| `status` | ENUM(...) | DEFAULT 'pending' | 任务状态: 'pending', 'processing', 'completed', 'failed' |
|
||||||
| `progress` | INT | DEFAULT 0 | 任务进度百分比 (0-100) |
|
| `progress` | INT | DEFAULT 0 | 任务进度百分比 (0-100) |
|
||||||
| `result` | TEXT | NULL | 成功时存储生成的纪要内容 |
|
| `result` | TEXT | NULL | 成功时存储导出的 Markdown 文件路径(如 `/uploads/...`) |
|
||||||
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 任务创建时间 |
|
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 任务创建时间 |
|
||||||
| `completed_at` | TIMESTAMP | NULL | 任务完成时间 |
|
| `completed_at` | TIMESTAMP | NULL | 任务完成时间 |
|
||||||
| `error_message` | TEXT | NULL | 错误信息记录 |
|
| `error_message` | TEXT | NULL | 错误信息记录 |
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,10 @@
|
||||||
# 组件
|
# 组件
|
||||||
+ 数据库 mysql 5.7+ 10.100.51.51:3306 root | Unis@123
|
+ 数据库 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 2gb
|
||||||
--maxmemory-policy allkeys-lru
|
--maxmemory-policy allkeys-lru
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
@ -93,9 +93,6 @@ services:
|
||||||
API_HOST: 0.0.0.0
|
API_HOST: 0.0.0.0
|
||||||
API_PORT: 8000
|
API_PORT: 8000
|
||||||
BASE_URL: ${BASE_URL:-http://localhost}
|
BASE_URL: ${BASE_URL:-http://localhost}
|
||||||
|
|
||||||
# LLM配置
|
|
||||||
QWEN_API_KEY: ${QWEN_API_KEY}
|
|
||||||
# 后端不直接暴露端口,通过nginx代理访问
|
# 后端不直接暴露端口,通过nginx代理访问
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
- "${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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>iMeeting - 智能会议助手</title>
|
<title>智听云平台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,64 @@ server {
|
||||||
proxy_send_timeout 300s;
|
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/ {
|
location ^~ /uploads/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.38.6",
|
"@codemirror/view": "^6.38.6",
|
||||||
|
|
@ -19,8 +20,7 @@
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"canvg": "^4.0.3",
|
"canvg": "^4.0.3",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jspdf": "^3.0.2",
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.294.0",
|
|
||||||
"markmap-common": "^0.18.9",
|
"markmap-common": "^0.18.9",
|
||||||
"markmap-lib": "^0.18.12",
|
"markmap-lib": "^0.18.12",
|
||||||
"markmap-view": "^0.18.12",
|
"markmap-view": "^0.18.12",
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,10 @@
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'MiSans', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #f8fafc;
|
background-color: transparent;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
@ -22,6 +20,347 @@ body {
|
||||||
width: 100%;
|
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 {
|
.app-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -83,7 +422,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
transform: translateY(-1px);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue