Compare commits

...

40 Commits

Author SHA1 Message Date
mula.liu d2ae4b63c6 修复进度bug 2026-04-15 14:25:46 +08:00
mula.liu f19b70f1d0 mcp bug fix 2026-04-14 18:36:22 +08:00
mula.liu f2c96f335f mcp bug fix 2026-04-14 13:19:21 +08:00
mula.liu f44453d93e 增加了mcp server 2026-04-14 12:37:48 +08:00
mula.liu d69b346f4c 增加了mcp server 2026-04-14 09:56:57 +08:00
mula.liu dbf6f72dac 修正了部署脚本 2026-04-13 17:55:39 +08:00
mula.liu c76d05edb4 修正了部署脚本 2026-04-13 17:46:10 +08:00
mula.liu 9079d82729 修正了部署脚本 2026-04-13 17:27:39 +08:00
mula.liu 60b4c1f3a6 init system 2026-04-13 14:55:33 +08:00
mula.liu e71bd889b1 v1.1.1 2026-04-13 09:03:23 +08:00
mula.liu 2c505514a5 v1.1.1 2026-04-09 22:09:33 +08:00
mula.liu d7507e811b fix bugs 2026-04-09 21:15:18 +08:00
mula.liu 27fa9317c5 v1.1.1 2026-04-09 20:51:05 +08:00
mula.liu 861d7e3463 v1.1.1 2026-04-09 19:43:00 +08:00
mula.liu 3fe28934cc v1.1.1 2026-04-09 17:51:34 +08:00
mula.liu 41f71e649d v1.1.1 2026-04-08 19:19:33 +08:00
mula.liu ad16567e82 v1.1.1 2026-04-08 17:29:06 +08:00
mula.liu aa99ee1f6a fix(asr): avoid serializing requests session in dashscope calls 2026-04-08 14:14:37 +08:00
mula.liu 31708df6cb v1.1.1 2026-04-08 13:29:54 +08:00
mula.liu f3d9429b28 1.1.1 2026-04-07 18:48:35 +08:00
mula.liu af735bd93d 1.1.1 2026-04-07 17:26:34 +08:00
mula.liu 3c2ac639b4 Merge remote-tracking branch 'origin/codex/dev' into codex/dev 2026-04-07 16:56:44 +08:00
mula.liu 2591996a48 修改了音频播放器 2026-04-07 15:51:51 +08:00
AlanPaine abc5342258 feat(meeting-details): 增强会议详情页的异步任务状态展示
- 在转录状态模型中添加 message 字段,用于传递任务状态信息
- 重构异步会议服务,支持提示词模板变量替换(会议标题、时间、创建者、参会者)
- 改进前端状态管理,添加上传进度显示和 AI 总结引导轮询
- 优化任务状态轮询逻辑,避免重复请求并提供更流畅的用户体验
2026-04-07 06:04:56 +00:00
mula.liu ac9c2f5fd4 1.1.1 2026-04-04 00:25:53 +08:00
mula.liu cec4f98d42 Merge remote-tracking branch 'origin/alan-dev' into codex/dev 2026-04-03 15:09:22 +08:00
mula.liu df61cd870d add migration script 2026-04-03 15:00:06 +08:00
AlanPaine cc1817078a x修复前端无法自动总结,修复点击说话人修改问题,新增双击编辑文本内容问题 2026-04-02 11:07:41 +00:00
AlanPaine a3ae293d42 更新统计人数 2026-04-01 09:41:35 +00:00
AlanPaine 181f4565b4 更新分享页面样式与道路页面描述 2026-04-01 08:36:52 +00:00
AlanPaine a99cc389c8 修复无法创建用户 2026-03-31 07:17:47 +00:00
AlanPaine a69dd645ca 更新接口 2026-03-30 08:22:09 +00:00
AlanPaine 423e768c3c 修复个人资料无法修改问题 2026-03-27 09:05:33 +00:00
AlanPaine 346b5ffb06 新增web访问密码查看 2026-03-27 08:01:52 +00:00
AlanPaine 7c63ec1ebe fix 解决访问密码,创建会议接口报错,后台docs无法打开问题 新增无数据库与redis 快速运行支持sh 2026-03-27 07:43:08 +00:00
AlanPaine 6e347be83b fix:React 19 与 antd v5 的兼容警告已接入官方补丁 2026-03-26 12:03:01 +00:00
AlanPaine c6188809ff 更新前端语法,系统环境要求等 2026-03-26 11:51:00 +00:00
mula.liu 498bd97f99 1.1.0 2026-03-26 17:32:31 +08:00
mula.liu 4715cd4a86 chore: release 1.1.0 2026-03-26 14:55:12 +08:00
mula.liu bbcc5466f0 修复ListTable行高对齐问题
- 修改.list-table-scroll为overflow-y: scroll,确保显示滚动条轨道
- 修改.list-table-actions为overflow-y: scroll,与数据列保持一致
- 两个表格现在会同时显示滚动条,保持行高完全对齐

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-12 15:34:12 +08:00
230 changed files with 27776 additions and 75962 deletions

View File

@ -1,15 +1,28 @@
# ==================== 部署模式说明 ====================
# 1. 默认 Docker 一体化部署(./start.sh / docker-compose.yml
# 只使用当前文件(根目录 .env和 Docker Compose 注入的环境变量,不读取 backend/.env。
# 2. 直接运行后端或外接中间件部署:
# `start-external.sh` 也只读取当前文件;
# 外接中间件时可直接在这里填写 MYSQL_* / REDIS_*,脚本会转换给后端使用。
# ==================== 数据库配置 ====================
# MySQL数据库配置
MYSQL_ROOT_PASSWORD=Unis@123
# MySQL 初始化参数Docker 内置 MySQL
# 当后端也运行在 Docker 中时,数据库主机应为服务名 `mysql`。
# 使用 `./start-external.sh` 时,请改成外部 MySQL 地址或服务名。
MYSQL_HOST=mysql
MYSQL_ROOT_PASSWORD=change_this_password
MYSQL_DATABASE=imeeting
MYSQL_USER=imeeting
MYSQL_PASSWORD=Unis@123
MYSQL_PASSWORD=change_this_password
MYSQL_PORT=3306
# ==================== 缓存配置 ====================
# Redis配置
# Redis 初始化参数Docker 内置 Redis
# 当后端也运行在 Docker 中时Redis 主机应为服务名 `redis`。
# 使用 `./start-external.sh` 时,请改成外部 Redis 地址或服务名。
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=Unis@123
REDIS_PASSWORD=change_this_password
REDIS_DB=0
# ==================== 应用端口配置 ====================
@ -17,14 +30,11 @@ REDIS_DB=0
HTTP_PORT=80
# ==================== 应用配置 ====================
# 应用访问地址(用于生成外部链接、二维码等)
# - 直接访问: http://服务器IP
# - 域名访问: https://your-domain.com 需配置接入服务器Nginx
BASE_URL=https://imeeting.unisspace.com
# 应用访问地址(用于生成外部链接、客户端下载链接,以及音频转录时提供给云端拉取音频文件的公网 URL
# - 本地联调可先填写: http://localhost
# - 使用云端音频转录时必须改成外部可访问的域名或公网地址不能填写容器名、127.0.0.1 或内网地址
# - 不要以 / 结尾,例如: https://your-domain.com
BASE_URL=https://your-domain.com
# 前端API地址通过Nginx代理访问后端
VITE_API_BASE_URL=/api
# ==================== LLM配置 ====================
# 通义千问API密钥请替换为实际密钥
QWEN_API_KEY=sk-c2bf06ea56b4491ea3d1e37fdb472b8f

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

147
.gitignore vendored
View File

@ -2,26 +2,40 @@
.DS_Store
Thumbs.db
# Editors and local tooling
.vscode/
.idea/
*.swp
*.swo
.claude/
.gemini-clipboard/
.memsearch/
frontend/.memsearch/
# Environment variables
.env
backend/.env
frontend/.env
frontend/.env.local
frontend/.env.development.local
frontend/.env.test.local
frontend/.env.production.local
# Docker / Data
# Project data and local-only assets
data/
backups/
logs_export/
# Node.js
node_modules/
frontend/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
frontend/dist/
frontend/logs/
backend/uploads/
backend/logs/
backend/venv/
backend/test/
# Only keep the latest full-deploy SQL entrypoints in repo
backend/sql/*
!backend/sql/imeeting-schema-latest.sql
!backend/sql/imeeting-seed-latest.sql
backend/sql/migrations/
backend/scripts/
资料/
# Python
__pycache__/
@ -29,13 +43,9 @@ __pycache__/
*$py.class
*.so
.Python
env/
venv/
.env/
.venv/
build/
develop-eggs/
dist/
develop-eggs/
downloads/
eggs/
.eggs/
@ -43,21 +53,104 @@ lib/
lib64/
parts/
sdist/
share/python-wheels/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
backend/venv/
backend/__pycache__/
backend/logs/
MANIFEST
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
.hypothesis/
.pytest_cache/
cover/
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
cython_debug/
.pdm.toml
__pypackages__/
.ipynb_checkpoints
.venv/
venv/
env/
ENV/
env.bak/
venv.bak/
instance/
.webassets-cache
.scrapy
docs/_build/
.pybuilder/
target/
profile_default/
ipython_config.py
celerybeat-schedule
celerybeat.pid
*.manifest
*.spec
pip-log.txt
pip-delete-this-directory.txt
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Project specific
backend/uploads/
资料/
# IDEs
.vscode/
.idea/
*.swp
*.swo
# Node.js / frontend
node_modules/
frontend/node_modules/
frontend/dist/
frontend/logs/
jspm_packages/
web_modules/
bower_components/
.npm
.parcel-cache/
.cache/
.next/
out/
.nuxt/
.vuepress/dist
.temp
.docusaurus
.serverless/
.fusebox/
.dynamodb/
.tern-port
.vscode-test
.eslintcache
.stylelintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.nyc_output
coverage/
*.lcov
*.tsbuildinfo
*.pid
*.pid.lock
*.seed
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.node_repl_history
*.tgz
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.yarn-integrity
.pnp.*
pids/
logs/

View File

@ -12,22 +12,19 @@
| `start.sh` ⭐ | 一键启动脚本(推荐) |
| `stop.sh` | 停止服务脚本 |
| `manage.sh` | 服务管理脚本 |
| `generate-ssl-cert.sh` | SSL证书生成脚本 |
| `DOCKER_DEPLOYMENT.md` | 详细部署文档 |
| `DOCKER_README.md` | 本文档 |
## 🏗️ 系统架构
```
方式一:直接访问
用户 → http://服务器IP → iMeeting Nginx (80) → Frontend/Backend
用户 → http://服务器IP → iMeeting Frontend(Web) (80) → Frontend/Backend
方式二域名访问HTTPS
用户 → https://domain → 接入服务器Nginx (SSL) → iMeeting服务器 (80) → Frontend/Backend
```
**5个服务容器**
- **Nginx**: HTTP入口仅HTTPSSL由接入服务器处理
**4个服务容器**
- **Frontend**: React前端应用
- **Backend**: FastAPI后端服务
- **MySQL**: 数据库
@ -43,43 +40,35 @@
```bash
# 1. 复制并配置环境变量
cp .env.example .env
vim .env # 配置七牛云、LLM密钥
vim .env # 配置 BASE_URL、密码
# 2. 一键启动
./start.sh
```
说明:
- 完整 Docker 一体化部署不会读取 `backend/.env`
脚本会自动完成:
- ✅ 检查Docker依赖
- ✅ 创建必要目录
- ✅ 生成SSL证书
- ✅ 配置后端环境变量
- ✅ 构建前端
- ✅ 启动所有服务
- ✅ 等待健康检查
说明:
- 后端镜像现在依赖系统级 `ffmpeg/ffprobe` 做音频预处理,已在 `backend/Dockerfile` 中安装,无需宿主机额外安装。
### 方式二:手动启动
```bash
# 1. 配置环境变量
cp .env.example .env
cp backend/.env.example backend/.env
vim .env # 配置主环境变量
vim backend/.env # 配置后端环境变量
# 2. 生成SSL证书
./generate-ssl-cert.sh
# 3. 构建前端
cd frontend
npm install
npm run build
cd ..
# 4. 启动所有服务
# 2. 启动所有服务
docker-compose up -d
# 5. 查看服务状态
# 3. 查看服务状态
docker-compose ps
```
@ -94,24 +83,19 @@ docker-compose ps
**域名访问HTTPS**
- 需要在接入服务器配置Nginx反向代理
- 参考文档:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
- 参考本文“域名和 HTTPS 配置”章节
- 访问示例https://imeeting.yourdomain.com
## ⚙️ 环境变量配置
### 必须配置项
编辑 `.env` 文件,修改以下配置:
编辑根目录 `.env` 文件,修改以下配置:
```bash
# 七牛云存储(必填,否则无法上传文件)
QINIU_ACCESS_KEY=your_actual_access_key
QINIU_SECRET_KEY=your_actual_secret_key
QINIU_BUCKET=your_bucket_name
QINIU_DOMAIN=your_domain.clouddn.com
# LLM API必填否则无法使用AI功能
QWEN_API_KEY=your_actual_qwen_api_key
# Docker 一体化部署下,后端容器访问内置 MySQL/Redis 使用服务名
MYSQL_HOST=mysql
REDIS_HOST=redis
# 生产环境必改密码
MYSQL_ROOT_PASSWORD=change_this_password
@ -122,18 +106,42 @@ REDIS_PASSWORD=change_this_password
### 可选配置项
```bash
# 应用访问地址(用于生成二维码等)
# 应用访问地址(用于生成外链,以及音频转录时给云端拉取音频文件)
# 如果使用云端音频转录,必须改成外部可访问的域名或公网地址,不能填写 localhost / 127.0.0.1 / 容器名
BASE_URL=http://localhost # 生产环境改为: https://your-domain.com
# Nginx端口默认80/443
HTTP_PORT=80
HTTPS_PORT=443
# 如需直接访问后端/前端(开发调试用)
# BACKEND_PORT=8001
# FRONTEND_PORT=3001
# 如需调整容器对外端口
# BACKEND_PORT=8000
# HTTP_PORT=80
```
### 外接 MySQL / Redis 部署
如果使用 `./start-external.sh`,直接修改根目录 `.env` 即可,不需要 `backend/.env`
```bash
cp .env.example .env
vim .env
```
根目录 `.env` 里需要重点配置:
- `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`
- `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` / `REDIS_PASSWORD`
- `BASE_URL`
说明:
- `start-external.sh` 会优先读取 `.env` 中的 `DB_*`;如果未设置,则自动回退到 `MYSQL_*`
### 音频预处理依赖
- Docker 部署:后端容器内已安装 `ffmpeg`
- 非 Docker 部署:请确保服务器可执行 `ffmpeg``ffprobe`
## 📦 数据目录
所有数据存储在 `./data/` 目录:
@ -146,7 +154,7 @@ data/
└── logs/ # 日志文件
├── backend/
├── frontend/
└── nginx/
└── frontend/
```
**重要提示**
@ -182,13 +190,13 @@ docker-compose ps
# 查看日志
docker-compose logs -f # 所有服务
docker-compose logs -f nginx # Nginx日志
docker-compose logs -f frontend # 前端Web日志
docker-compose logs -f backend # 后端日志
tail -f data/logs/nginx/imeeting_access.log # 访问日志
tail -f data/logs/frontend/*.log # 前端容器日志目录
# 重启服务
docker-compose restart # 重启所有
docker-compose restart nginx # 重启Nginx
docker-compose restart frontend # 重启前端
# 停止服务
./stop.sh # 交互式停止
@ -201,7 +209,7 @@ iMeeting服务器本身仅提供HTTP服务。如需通过域名访问HTTPS
### 配置接入服务器
完整配置步骤请参考[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
完整配置步骤请参考本文档中的反向代理章节
**简要步骤**
1. 在接入服务器安装Nginx
@ -225,7 +233,7 @@ server {
}
```
详见:[GATEWAY_NGINX_CONFIG.md](GATEWAY_NGINX_CONFIG.md)
详见:本文档中的反向代理章节
## 🔍 故障排查
@ -236,7 +244,7 @@ server {
docker-compose logs -f
# 文件日志
tail -f data/logs/nginx/imeeting_error.log
tail -f data/logs/frontend/*.log
tail -f data/logs/backend/*.log
```
@ -267,19 +275,19 @@ curl http://localhost/docs
|------|----------|
| 端口被占用 | 修改.env中的HTTP_PORT |
| 502错误 | 检查backend和frontend是否健康 |
| 数据库连接失败 | 检查backend/.env配置 |
| 数据库连接失败 | 检查根目录 `.env` 中的数据库配置;外接中间件模式确认 `MYSQL_HOST` / `DB_HOST` 是否正确 |
| 前端无法访问API | 检查VITE_API_BASE_URL配置 |
| 如何配置HTTPS | 参考GATEWAY_NGINX_CONFIG.md配置接入服务器 |
| 音频转录拉不到音频文件 | 检查 `BASE_URL` 是否为云端可访问的完整地址 |
| 如何配置HTTPS | 参考本文档中的反向代理章节 |
详见:`DOCKER_DEPLOYMENT.md` 故障排查章节
详见:`DOCKER_README.md` 故障排查章节
## 📊 服务组件
| 服务 | 容器名 | 内部端口 | 外部端口 | 健康检查 |
|------|--------|----------|----------|----------|
| Nginx | imeeting-nginx | 80 | 80 | ✅ |
| Frontend | imeeting-frontend | 3001 | - | ✅ |
| Backend | imeeting-backend | 8001 | - | ✅ |
| Frontend(Web) | imeeting-frontend | 80 | 80 | ✅ |
| Backend | imeeting-backend | 8000 | 8000 | ✅ |
| MySQL | imeeting-mysql | 3306 | - | ✅ |
| Redis | imeeting-redis | 6379 | - | ✅ |
@ -309,7 +317,7 @@ npm run build
cd ..
docker-compose build frontend
docker-compose up -d frontend
docker-compose restart nginx
docker-compose restart frontend
```
### 仅更新后端
@ -338,7 +346,7 @@ docker-compose exec mysql mysqldump -uroot -p imeeting > backup.sql
tar czf imeeting_backup_$(date +%Y%m%d).tar.gz data/
# 备份配置
tar czf imeeting_config_$(date +%Y%m%d).tar.gz .env backend/.env nginx/
tar czf imeeting_config_$(date +%Y%m%d).tar.gz .env frontend/nginx.conf
```
### 恢复数据
@ -366,8 +374,8 @@ tar xzf imeeting_backup_20240101.tar.gz
3. ✅ **设置文件权限**
```bash
chmod 600 .env backend/.env
chmod 600 nginx/ssl/server.key
chmod 600 .env
确保接入层反向代理证书文件权限正确
```
4. ✅ **启用防火墙**
@ -389,8 +397,8 @@ tar xzf imeeting_backup_20240101.tar.gz
详细的部署说明、配置选项、性能优化等,请查看:
- **详细部署文档**: [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md)
- **Nginx配置说明**: [nginx/README.md](nginx/README.md)
- **部署说明**: [DOCKER_README.md](DOCKER_README.md)
- **前端代理配置**: [frontend/nginx.conf](frontend/nginx.conf)
## 🆘 获取帮助
@ -398,7 +406,7 @@ tar xzf imeeting_backup_20240101.tar.gz
1. 查看日志:`docker-compose logs -f`
2. 查看健康状态:`docker-compose ps`
3. 查看详细文档:`DOCKER_DEPLOYMENT.md`
3. 查看详细文档:`DOCKER_README.md`
4. 提交Issue到项目仓库
## 📞 快速命令参考

View File

@ -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

View File

@ -82,8 +82,8 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
### 环境要求
- Node.js 16+
- Python 3.9+
- Node.js 22.12+
- Python 3.12+
- MySQL 5.7+
- Redis 5.0+
- Docker (可选)
@ -95,7 +95,7 @@ iMeeting 是一个基于 AI 技术的智能会议记录与内容管理平台,通
```bash
cd backend
pip install -r requirements.txt
python main.py
python app/main.py
```
默认运行在 `http://localhost:8000`
@ -110,6 +110,17 @@ npm run dev
默认运行在 `http://localhost:5173`
#### 使用 Conda 一键启动前后端
项目根目录提供了 Conda 启动脚本,会分别创建并使用独立环境启动前后端:
- 后端环境: `imetting_backend` (Python 3.12)
- 前端环境: `imetting_frontend` (Node.js 22)
```bash
./start-conda.sh
```
### 配置说明
详细的配置文档请参考:

View File

@ -13,6 +13,9 @@ ENV/
# 用户上传文件(最重要!)
uploads/
test/
sql/
scripts/
# 测试和开发文件
test/
@ -38,6 +41,8 @@ htmlcov/
logs/
# 环境变量
.env
.env.*
.env.local
.env.*.local

View File

@ -1,31 +1,30 @@
# ==================== 数据库配置 ====================
# Docker环境使用容器名称
DB_HOST=10.100.51.51
DB_USER=root
DB_PASSWORD=Unis@123
DB_NAME=imeeting_dev
# 仅供“宿主机直接运行 backend”使用
# 如果 backend 运行在 Docker 中,且数据库也在同一 Docker 网络,主机名应填服务名(如 mysql
# 如果是宿主机直接运行 backend再填写 127.0.0.1 或实际地址
DB_HOST=127.0.0.1
DB_USER=imeeting
DB_PASSWORD=change_this_password
DB_NAME=imeeting
DB_PORT=3306
# ==================== Redis配置 ====================
# Docker环境使用容器名称
REDIS_HOST=10.100.51.51
# 如果 backend 运行在 Docker 中,且 Redis 也在同一 Docker 网络,主机名应填服务名(如 redis
# 如果是宿主机直接运行 backend再填写 127.0.0.1 或实际地址
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=Unis@123
REDIS_PASSWORD=change_this_password
# ==================== API配置 ====================
API_HOST=0.0.0.0
API_PORT=8001
API_PORT=8000
# ==================== 应用配置 ====================
# 应用访问地址(用于生成外部链接、二维码等)
# 开发环境: http://localhost
# 生产环境: https://your-domain.com
BASE_URL=http://imeeting.unisspace.com
# ==================== LLM配置 ====================
# 通义千问API密钥请替换为实际密钥
QWEN_API_KEY=sk-c2bf06ea56b4491ea3d1e37fdb472b8f
# 直接运行 backend 时可在这里配置 BASE_URL
# Docker 容器部署时,优先使用仓库根目录 .env 中的 BASE_URL。
# 使用云端音频转录时,必须填写云端可以访问到的公网地址,且不要以 / 结尾。
# BASE_URL=https://your-domain.com
# ==================== 转录轮询配置 ====================

163
backend/.gitignore vendored
View File

@ -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/

View File

@ -19,6 +19,7 @@ COPY requirements.txt .
RUN apt-get update && apt-get install -y \
gcc \
curl \
ffmpeg \
default-libmysqlclient-dev \
pkg-config \
&& pip install --index-url https://mirrors.aliyun.com/pypi/simple --no-cache-dir -r requirements.txt \

View File

@ -1,189 +1,83 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from app.core.auth import get_current_admin_user, get_current_user
from app.core.response import create_api_response
from app.core.database import get_db_connection
from app.models.models import MenuInfo, MenuListResponse, RolePermissionInfo, UpdateRolePermissionsRequest, RoleInfo
from typing import List
from app.models.models import (
CreateMenuRequest,
CreateRoleRequest,
UpdateMenuRequest,
UpdateRolePermissionsRequest,
UpdateRoleRequest,
)
import app.services.admin_service as admin_service
router = APIRouter()
# ========== 菜单权限管理接口 ==========
@router.get("/admin/menus")
async def get_all_menus(current_user=Depends(get_current_admin_user)):
"""
获取所有菜单列表
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = """
SELECT menu_id, menu_code, menu_name, menu_icon, menu_url, menu_type,
parent_id, sort_order, is_active, description, created_at, updated_at
FROM menus
ORDER BY sort_order ASC, menu_id ASC
"""
cursor.execute(query)
menus = cursor.fetchall()
return admin_service.get_all_menus()
menu_list = [MenuInfo(**menu) for menu in menus]
return create_api_response(
code="200",
message="获取菜单列表成功",
data=MenuListResponse(menus=menu_list, total=len(menu_list))
)
except Exception as e:
return create_api_response(code="500", message=f"获取菜单列表失败: {str(e)}")
@router.post("/admin/menus")
async def create_menu(request: CreateMenuRequest, current_user=Depends(get_current_admin_user)):
return admin_service.create_menu(request)
@router.put("/admin/menus/{menu_id}")
async def update_menu(menu_id: int, request: UpdateMenuRequest, current_user=Depends(get_current_admin_user)):
return admin_service.update_menu(menu_id, request)
@router.delete("/admin/menus/{menu_id}")
async def delete_menu(menu_id: int, current_user=Depends(get_current_admin_user)):
return admin_service.delete_menu(menu_id)
@router.get("/admin/roles")
async def get_all_roles(current_user=Depends(get_current_admin_user)):
"""
获取所有角色列表及其权限统计
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
return admin_service.get_all_roles()
# 查询所有角色及其权限数量
query = """
SELECT r.role_id, r.role_name, r.created_at,
COUNT(rmp.menu_id) as menu_count
FROM roles r
LEFT JOIN role_menu_permissions rmp ON r.role_id = rmp.role_id
GROUP BY r.role_id
ORDER BY r.role_id ASC
"""
cursor.execute(query)
roles = cursor.fetchall()
return create_api_response(
code="200",
message="获取角色列表成功",
data={"roles": roles, "total": len(roles)}
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色列表失败: {str(e)}")
@router.post("/admin/roles")
async def create_role(request: CreateRoleRequest, current_user=Depends(get_current_admin_user)):
return admin_service.create_role(request)
@router.put("/admin/roles/{role_id}")
async def update_role(role_id: int, request: UpdateRoleRequest, current_user=Depends(get_current_admin_user)):
return admin_service.update_role(role_id, request)
@router.get("/admin/roles/{role_id}/users")
async def get_role_users(
role_id: int,
page: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=100),
current_user=Depends(get_current_admin_user),
):
return admin_service.get_role_users(role_id, page, size)
@router.get("/admin/roles/permissions/all")
async def get_all_role_permissions(current_user=Depends(get_current_admin_user)):
return admin_service.get_all_role_permissions()
@router.get("/admin/roles/{role_id}/permissions")
async def get_role_permissions(role_id: int, current_user=Depends(get_current_admin_user)):
"""
获取指定角色的菜单权限
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
return admin_service.get_role_permissions(role_id)
# 检查角色是否存在
cursor.execute("SELECT role_id, role_name FROM roles WHERE role_id = %s", (role_id,))
role = cursor.fetchone()
if not role:
return create_api_response(code="404", message="角色不存在")
# 查询该角色的所有菜单权限
query = """
SELECT menu_id
FROM role_menu_permissions
WHERE role_id = %s
"""
cursor.execute(query, (role_id,))
permissions = cursor.fetchall()
menu_ids = [p['menu_id'] for p in permissions]
return create_api_response(
code="200",
message="获取角色权限成功",
data=RolePermissionInfo(
role_id=role['role_id'],
role_name=role['role_name'],
menu_ids=menu_ids
)
)
except Exception as e:
return create_api_response(code="500", message=f"获取角色权限失败: {str(e)}")
@router.put("/admin/roles/{role_id}/permissions")
async def update_role_permissions(
role_id: int,
request: UpdateRolePermissionsRequest,
current_user=Depends(get_current_admin_user)
current_user=Depends(get_current_admin_user),
):
"""
更新指定角色的菜单权限
只有管理员才能访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
return admin_service.update_role_permissions(role_id, request)
# 检查角色是否存在
cursor.execute("SELECT role_id FROM roles WHERE role_id = %s", (role_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="角色不存在")
# 验证所有menu_id是否有效
if request.menu_ids:
format_strings = ','.join(['%s'] * len(request.menu_ids))
cursor.execute(
f"SELECT COUNT(*) as count FROM menus WHERE menu_id IN ({format_strings})",
tuple(request.menu_ids)
)
valid_count = cursor.fetchone()['count']
if valid_count != len(request.menu_ids):
return create_api_response(code="400", message="包含无效的菜单ID")
# 删除该角色的所有现有权限
cursor.execute("DELETE FROM role_menu_permissions WHERE role_id = %s", (role_id,))
# 插入新的权限
if request.menu_ids:
insert_values = [(role_id, menu_id) for menu_id in request.menu_ids]
cursor.executemany(
"INSERT INTO role_menu_permissions (role_id, menu_id) VALUES (%s, %s)",
insert_values
)
connection.commit()
return create_api_response(
code="200",
message="更新角色权限成功",
data={"role_id": role_id, "menu_count": len(request.menu_ids)}
)
except Exception as e:
return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}")
@router.get("/menus/user")
async def get_user_menus(current_user=Depends(get_current_user)):
"""
获取当前用户可访问的菜单列表用于渲染下拉菜单
所有登录用户都可以访问
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 根据用户的role_id查询可访问的菜单
query = """
SELECT DISTINCT m.menu_id, m.menu_code, m.menu_name, m.menu_icon,
m.menu_url, m.menu_type, m.sort_order
FROM menus m
JOIN role_menu_permissions rmp ON m.menu_id = rmp.menu_id
WHERE rmp.role_id = %s AND m.is_active = 1
ORDER BY m.sort_order ASC
"""
cursor.execute(query, (current_user['role_id'],))
menus = cursor.fetchall()
return create_api_response(
code="200",
message="获取用户菜单成功",
data={"menus": menus}
)
except Exception as e:
return create_api_response(code="500", message=f"获取用户菜单失败: {str(e)}")
return admin_service.get_user_menus(current_user)

View File

@ -1,248 +1,25 @@
from fastapi import APIRouter, Depends, Query
from app.core.auth import get_current_admin_user
from app.core.response import create_api_response
from app.core.database import get_db_connection
from app.services.jwt_service import jwt_service
from app.core.config import AUDIO_DIR, REDIS_CONFIG
from datetime import datetime
from typing import Dict, List
import os
import redis
import app.services.admin_dashboard_service as admin_dashboard_service
router = APIRouter()
# Redis 客户端
redis_client = redis.Redis(**REDIS_CONFIG)
# 常量定义
AUDIO_FILE_EXTENSIONS = ('.wav', '.mp3', '.m4a', '.aac', '.flac', '.ogg')
BYTES_TO_GB = 1024 ** 3
def _build_status_condition(status: str) -> str:
"""构建任务状态查询条件"""
if status == 'running':
return "AND (t.status = 'pending' OR t.status = 'processing')"
elif status == 'completed':
return "AND t.status = 'completed'"
elif status == 'failed':
return "AND t.status = 'failed'"
return ""
def _get_task_stats_query() -> str:
"""获取任务统计的 SQL 查询"""
return """
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' OR status = 'processing' THEN 1 ELSE 0 END) as running,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
"""
def _get_online_user_count(redis_client) -> int:
"""从 Redis 获取在线用户数"""
try:
token_keys = redis_client.keys("token:*")
user_ids = set()
for key in token_keys:
parts = key.split(':')
if len(parts) >= 2:
user_ids.add(parts[1])
return len(user_ids)
except Exception as e:
print(f"获取在线用户数失败: {e}")
return 0
def _calculate_audio_storage() -> Dict[str, float]:
"""计算音频文件存储统计"""
audio_files_count = 0
audio_total_size = 0
try:
if os.path.exists(AUDIO_DIR):
for root, _, files in os.walk(AUDIO_DIR):
for file in files:
if file.endswith(AUDIO_FILE_EXTENSIONS):
audio_files_count += 1
file_path = os.path.join(root, file)
try:
audio_total_size += os.path.getsize(file_path)
except OSError:
continue
except Exception as e:
print(f"统计音频文件失败: {e}")
return {
"audio_files_count": audio_files_count,
"audio_total_size_gb": round(audio_total_size / BYTES_TO_GB, 2)
}
@router.get("/admin/dashboard/stats")
async def get_dashboard_stats(current_user=Depends(get_current_admin_user)):
"""获取管理员 Dashboard 统计数据"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 1. 用户统计
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
cursor.execute("SELECT COUNT(*) as total FROM users")
total_users = cursor.fetchone()['total']
cursor.execute(
"SELECT COUNT(*) as count FROM users WHERE created_at >= %s",
(today_start,)
)
today_new_users = cursor.fetchone()['count']
online_users = _get_online_user_count(redis_client)
# 2. 会议统计
cursor.execute("SELECT COUNT(*) as total FROM meetings")
total_meetings = cursor.fetchone()['total']
cursor.execute(
"SELECT COUNT(*) as count FROM meetings WHERE created_at >= %s",
(today_start,)
)
today_new_meetings = cursor.fetchone()['count']
# 3. 任务统计
task_stats_query = _get_task_stats_query()
# 转录任务
cursor.execute(f"{task_stats_query} FROM transcript_tasks")
transcription_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 总结任务
cursor.execute(f"{task_stats_query} FROM llm_tasks")
summary_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 知识库任务
cursor.execute(f"{task_stats_query} FROM knowledge_base_tasks")
kb_stats = cursor.fetchone() or {'total': 0, 'running': 0, 'completed': 0, 'failed': 0}
# 4. 音频存储统计
storage_stats = _calculate_audio_storage()
# 组装返回数据
stats = {
"users": {
"total": total_users,
"today_new": today_new_users,
"online": online_users
},
"meetings": {
"total": total_meetings,
"today_new": today_new_meetings
},
"tasks": {
"transcription": {
"total": transcription_stats['total'] or 0,
"running": transcription_stats['running'] or 0,
"completed": transcription_stats['completed'] or 0,
"failed": transcription_stats['failed'] or 0
},
"summary": {
"total": summary_stats['total'] or 0,
"running": summary_stats['running'] or 0,
"completed": summary_stats['completed'] or 0,
"failed": summary_stats['failed'] or 0
},
"knowledge_base": {
"total": kb_stats['total'] or 0,
"running": kb_stats['running'] or 0,
"completed": kb_stats['completed'] or 0,
"failed": kb_stats['failed'] or 0
}
},
"storage": storage_stats
}
return create_api_response(code="200", message="获取统计数据成功", data=stats)
except Exception as e:
print(f"获取Dashboard统计数据失败: {e}")
return create_api_response(code="500", message=f"获取统计数据失败: {str(e)}")
return await admin_dashboard_service.get_dashboard_stats(current_user)
@router.get("/admin/online-users")
async def get_online_users(current_user=Depends(get_current_admin_user)):
"""获取在线用户列表"""
try:
token_keys = redis_client.keys("token:*")
# 提取用户ID并去重
user_tokens = {}
for key in token_keys:
parts = key.split(':')
if len(parts) >= 3:
user_id = int(parts[1])
token = parts[2]
if user_id not in user_tokens:
user_tokens[user_id] = []
user_tokens[user_id].append({'token': token, 'key': key})
# 查询用户信息
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
online_users_list = []
for user_id, tokens in user_tokens.items():
cursor.execute(
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
(user_id,)
)
user = cursor.fetchone()
if user:
ttl_seconds = redis_client.ttl(tokens[0]['key'])
online_users_list.append({
**user,
'token_count': len(tokens),
'ttl_seconds': ttl_seconds,
'ttl_hours': round(ttl_seconds / 3600, 1) if ttl_seconds > 0 else 0
})
# 按用户ID排序
online_users_list.sort(key=lambda x: x['user_id'])
return create_api_response(
code="200",
message="获取在线用户列表成功",
data={"users": online_users_list, "total": len(online_users_list)}
)
except Exception as e:
print(f"获取在线用户列表失败: {e}")
return create_api_response(code="500", message=f"获取在线用户列表失败: {str(e)}")
return await admin_dashboard_service.get_online_users(current_user)
@router.post("/admin/kick-user/{user_id}")
async def kick_user(user_id: int, current_user=Depends(get_current_admin_user)):
"""踢出用户(撤销该用户的所有 token"""
try:
revoked_count = jwt_service.revoke_all_user_tokens(user_id)
if revoked_count > 0:
return create_api_response(
code="200",
message=f"已踢出用户,撤销了 {revoked_count} 个 token",
data={"user_id": user_id, "revoked_count": revoked_count}
)
else:
return create_api_response(
code="404",
message="该用户当前不在线或未找到 token"
)
except Exception as e:
print(f"踢出用户失败: {e}")
return create_api_response(code="500", message=f"踢出用户失败: {str(e)}")
return await admin_dashboard_service.kick_user(user_id, current_user)
@router.get("/admin/tasks/monitor")
@ -252,207 +29,19 @@ async def monitor_tasks(
limit: int = Query(20, ge=1, le=100, description="返回数量限制"),
current_user=Depends(get_current_admin_user)
):
"""监控任务进度"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
tasks = []
status_condition = _build_status_condition(status)
# 转录任务
if task_type in ['all', 'transcription']:
query = f"""
SELECT
t.task_id,
'transcription' as task_type,
t.meeting_id,
m.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.completed_at,
u.username as creator_name
FROM transcript_tasks t
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
LEFT JOIN users u ON m.user_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 总结任务
if task_type in ['all', 'summary']:
query = f"""
SELECT
t.task_id,
'summary' as task_type,
t.meeting_id,
m.title as meeting_title,
t.status,
NULL as progress,
t.error_message,
t.created_at,
t.completed_at,
u.username as creator_name
FROM llm_tasks t
LEFT JOIN meetings m ON t.meeting_id = m.meeting_id
LEFT JOIN users u ON m.user_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 知识库任务
if task_type in ['all', 'knowledge_base']:
query = f"""
SELECT
t.task_id,
'knowledge_base' as task_type,
t.kb_id as meeting_id,
k.title as meeting_title,
t.status,
t.progress,
t.error_message,
t.created_at,
t.updated_at,
u.username as creator_name
FROM knowledge_base_tasks t
LEFT JOIN knowledge_bases k ON t.kb_id = k.kb_id
LEFT JOIN users u ON k.creator_id = u.user_id
WHERE 1=1 {status_condition}
ORDER BY t.created_at DESC
LIMIT %s
"""
cursor.execute(query, (limit,))
tasks.extend(cursor.fetchall())
# 按创建时间排序并限制返回数量
tasks.sort(key=lambda x: x['created_at'], reverse=True)
tasks = tasks[:limit]
return create_api_response(
code="200",
message="获取任务监控数据成功",
data={"tasks": tasks, "total": len(tasks)}
)
except Exception as e:
print(f"获取任务监控数据失败: {e}")
import traceback
traceback.print_exc()
return create_api_response(code="500", message=f"获取任务监控数据失败: {str(e)}")
return await admin_dashboard_service.monitor_tasks(task_type, status, limit, current_user)
@router.get("/admin/system/resources")
async def get_system_resources(current_user=Depends(get_current_admin_user)):
"""获取服务器资源使用情况"""
try:
import psutil
# CPU 使用率
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
# 内存使用情况
memory = psutil.virtual_memory()
memory_total_gb = round(memory.total / BYTES_TO_GB, 2)
memory_used_gb = round(memory.used / BYTES_TO_GB, 2)
# 磁盘使用情况
disk = psutil.disk_usage('/')
disk_total_gb = round(disk.total / BYTES_TO_GB, 2)
disk_used_gb = round(disk.used / BYTES_TO_GB, 2)
resources = {
"cpu": {
"percent": cpu_percent,
"count": cpu_count
},
"memory": {
"total_gb": memory_total_gb,
"used_gb": memory_used_gb,
"percent": memory.percent
},
"disk": {
"total_gb": disk_total_gb,
"used_gb": disk_used_gb,
"percent": disk.percent
},
"timestamp": datetime.now().isoformat()
}
return create_api_response(code="200", message="获取系统资源成功", data=resources)
except ImportError:
return create_api_response(
code="500",
message="psutil 库未安装,请运行: pip install psutil"
)
except Exception as e:
print(f"获取系统资源失败: {e}")
return create_api_response(code="500", message=f"获取系统资源失败: {str(e)}")
return await admin_dashboard_service.get_system_resources(current_user)
@router.get("/admin/user-stats")
async def get_user_stats(current_user=Depends(get_current_admin_user)):
"""获取用户统计列表"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
return await admin_dashboard_service.get_user_stats(current_user)
# 查询所有用户及其会议统计和最后登录时间(排除没有会议的用户)
query = """
SELECT
u.user_id,
u.username,
u.caption,
u.created_at,
(SELECT MAX(created_at) FROM user_logs
WHERE user_id = u.user_id AND action_type = 'login') as last_login_time,
COUNT(DISTINCT m.meeting_id) as meeting_count,
COALESCE(SUM(af.duration), 0) as total_duration_seconds
FROM users u
INNER JOIN meetings m ON u.user_id = m.user_id
LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id
GROUP BY u.user_id, u.username, u.caption, u.created_at
HAVING meeting_count > 0
ORDER BY u.user_id ASC
"""
cursor.execute(query)
users = cursor.fetchall()
# 格式化返回数据
users_list = []
for user in users:
total_seconds = int(user['total_duration_seconds']) if user['total_duration_seconds'] else 0
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
users_list.append({
'user_id': user['user_id'],
'username': user['username'],
'caption': user['caption'],
'created_at': user['created_at'].isoformat() if user['created_at'] else None,
'last_login_time': user['last_login_time'].isoformat() if user['last_login_time'] else None,
'meeting_count': user['meeting_count'],
'total_duration_seconds': total_seconds,
'total_duration_formatted': f"{hours}h {minutes}m" if total_seconds > 0 else '-'
})
return create_api_response(
code="200",
message="获取用户统计成功",
data={"users": users_list, "total": len(users_list)}
)
except Exception as e:
print(f"获取用户统计失败: {e}")
import traceback
traceback.print_exc()
return create_api_response(code="500", message=f"获取用户统计失败: {str(e)}")
@router.post("/admin/tasks/{task_type}/{task_id}/retry")
async def retry_task(task_type: str, task_id: str, current_user=Depends(get_current_admin_user)):
return await admin_dashboard_service.retry_task(task_type, task_id, current_user)

View File

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

View File

@ -4,9 +4,7 @@ from app.core.config import BASE_DIR, AUDIO_DIR, TEMP_UPLOAD_DIR
from app.core.auth import get_current_user
from app.core.response import create_api_response
from app.services.async_transcription_service import AsyncTranscriptionService
from app.services.async_meeting_service import async_meeting_service
from app.services.audio_service import handle_audio_upload
from app.utils.audio_parser import get_audio_duration
from app.services.audio_upload_task_service import audio_upload_task_service
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta
@ -456,55 +454,25 @@ async def complete_upload(
}
)
# 6. 获取文件信息
# 6. 提交后台任务,异步执行预处理和转录启动
full_path = BASE_DIR / file_path.lstrip('/')
file_size = full_path.stat().st_size
file_name = full_path.name
# 6.5 获取音频时长
audio_duration = 0
try:
audio_duration = get_audio_duration(str(full_path))
print(f"音频时长: {audio_duration}")
except Exception as e:
print(f"警告: 获取音频时长失败,但不影响后续流程: {e}")
# 7. 调用 audio_service 处理文件(数据库更新、启动转录和总结)
result = handle_audio_upload(
file_path=file_path,
file_name=file_name,
file_size=file_size,
transcription_task_id = audio_upload_task_service.enqueue_upload_processing(
meeting_id=request.meeting_id,
original_file_path=file_path,
current_user=current_user,
auto_summarize=request.auto_summarize,
background_tasks=background_tasks,
prompt_id=request.prompt_id, # 传递提示词模版ID
duration=audio_duration # 传递时长参数
prompt_id=request.prompt_id,
)
# 如果处理失败,返回错误
if not result["success"]:
return result["response"]
# 8. 返回成功响应
transcription_task_id = result["transcription_task_id"]
message_suffix = ""
if transcription_task_id:
if request.auto_summarize:
message_suffix = ",正在进行转录和总结"
else:
message_suffix = ",正在进行转录"
return create_api_response(
code="200",
message="音频上传完成" + message_suffix,
message="音频上传完成,后台正在处理音频" + ("并准备总结" if request.auto_summarize else ""),
data={
"meeting_id": request.meeting_id,
"file_path": file_path,
"file_size": file_size,
"duration": audio_duration,
"task_id": transcription_task_id,
"task_status": "pending" if transcription_task_id else None,
"task_status": "processing",
"background_processing": True,
"auto_summarize": request.auto_summarize
}
)

View File

@ -7,7 +7,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.core.auth import get_current_user
from app.core.database import get_db_connection
from app.models.models import LoginRequest, LoginResponse
from app.models.models import LoginRequest, LoginResponse, UserInfo
from app.services.jwt_service import jwt_service
from app.core.response import create_api_response
@ -23,7 +23,21 @@ def login(request_body: LoginRequest, request: Request):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = "SELECT user_id, username, caption, avatar_url, email, password_hash, role_id FROM users WHERE username = %s"
query = """
SELECT
u.user_id,
u.username,
u.caption,
u.avatar_url,
u.email,
u.password_hash,
u.role_id,
u.created_at,
COALESCE(r.role_name, '普通用户') AS role_name
FROM sys_users u
LEFT JOIN sys_roles r ON r.role_id = u.role_id
WHERE u.username = %s
"""
cursor.execute(query, (request_body.username,))
user = cursor.fetchone()
@ -67,19 +81,23 @@ def login(request_body: LoginRequest, request: Request):
print(f"Failed to log user login: {e}")
login_response_data = LoginResponse(
user_id=user['user_id'],
username=user['username'],
caption=user['caption'],
avatar_url=user['avatar_url'],
email=user['email'],
token=token,
role_id=user['role_id']
user=UserInfo(
user_id=user['user_id'],
username=user['username'],
caption=user['caption'],
email=user.get('email'),
role_id=user['role_id'],
role_name=user.get('role_name') or '普通用户',
avatar_url=user.get('avatar_url'),
created_at=user['created_at']
)
)
return create_api_response(
code="200",
message="登录成功",
data=login_response_data.dict()
data=login_response_data.model_dump()
)
@router.post("/auth/logout")

View File

@ -22,7 +22,8 @@ async def get_client_downloads(
platform_code: Optional[str] = None,
is_active: Optional[bool] = None,
page: int = 1,
size: int = 50
size: int = 50,
current_user: dict = Depends(get_current_admin_user)
):
"""
获取客户端下载列表管理后台接口
@ -102,7 +103,7 @@ async def get_latest_clients():
query = """
SELECT cd.*, dd.label_cn, dd.label_en, dd.parent_code, dd.extension_attr
FROM client_downloads cd
LEFT JOIN dict_data dd ON cd.platform_code = dd.dict_code
LEFT JOIN sys_dict_data dd ON cd.platform_code = dd.dict_code
AND dd.dict_type = 'client_platform'
WHERE cd.is_active = TRUE AND cd.is_latest = TRUE
ORDER BY dd.parent_code, dd.sort_order, cd.platform_code

View File

@ -60,7 +60,7 @@ async def get_dict_types():
query = """
SELECT DISTINCT dict_type
FROM dict_data
FROM sys_dict_data
WHERE status = 1
ORDER BY dict_type
"""
@ -99,7 +99,7 @@ async def get_dict_data_by_type(dict_type: str, parent_code: Optional[str] = Non
SELECT id, dict_type, dict_code, parent_code, tree_path,
label_cn, label_en, sort_order, extension_attr,
is_default, status, create_time
FROM dict_data
FROM sys_dict_data
WHERE dict_type = %s AND status = 1
"""
params = [dict_type]
@ -187,7 +187,7 @@ async def get_dict_data_by_code(dict_type: str, dict_code: str):
SELECT id, dict_type, dict_code, parent_code, tree_path,
label_cn, label_en, sort_order, extension_attr,
is_default, status, create_time, update_time
FROM dict_data
FROM sys_dict_data
WHERE dict_type = %s AND dict_code = %s
LIMIT 1
"""
@ -246,7 +246,7 @@ async def create_dict_data(
# 检查是否已存在
cursor.execute(
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
"SELECT id FROM sys_dict_data WHERE dict_type = %s AND dict_code = %s",
(request.dict_type, request.dict_code)
)
if cursor.fetchone():
@ -258,7 +258,7 @@ async def create_dict_data(
# 插入数据
query = """
INSERT INTO dict_data (
INSERT INTO sys_dict_data (
dict_type, dict_code, parent_code, label_cn, label_en,
sort_order, extension_attr, is_default, status
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
@ -319,7 +319,7 @@ async def update_dict_data(
cursor = conn.cursor(dictionary=True)
# 检查是否存在
cursor.execute("SELECT * FROM dict_data WHERE id = %s", (id,))
cursor.execute("SELECT * FROM sys_dict_data WHERE id = %s", (id,))
existing = cursor.fetchone()
if not existing:
cursor.close()
@ -369,7 +369,7 @@ async def update_dict_data(
# 执行更新
update_query = f"""
UPDATE dict_data
UPDATE sys_dict_data
SET {', '.join(update_fields)}
WHERE id = %s
"""
@ -404,7 +404,7 @@ async def delete_dict_data(
cursor = conn.cursor(dictionary=True)
# 检查是否存在
cursor.execute("SELECT dict_code FROM dict_data WHERE id = %s", (id,))
cursor.execute("SELECT dict_code FROM sys_dict_data WHERE id = %s", (id,))
existing = cursor.fetchone()
if not existing:
cursor.close()
@ -415,7 +415,7 @@ async def delete_dict_data(
# 检查是否有子节点
cursor.execute(
"SELECT COUNT(*) as count FROM dict_data WHERE parent_code = %s",
"SELECT COUNT(*) as count FROM sys_dict_data WHERE parent_code = %s",
(existing['dict_code'],)
)
if cursor.fetchone()['count'] > 0:
@ -438,7 +438,7 @@ async def delete_dict_data(
)
# 执行删除
cursor.execute("DELETE FROM dict_data WHERE id = %s", (id,))
cursor.execute("DELETE FROM sys_dict_data WHERE id = %s", (id,))
conn.commit()
cursor.close()

View File

@ -76,7 +76,7 @@ async def get_external_apps(
list_query = f"""
SELECT ea.*, u.username as creator_username
FROM external_apps ea
LEFT JOIN users u ON ea.created_by = u.user_id
LEFT JOIN sys_users u ON ea.created_by = u.user_id
WHERE {where_clause}
ORDER BY ea.sort_order ASC, ea.created_at DESC
"""

View File

@ -2,11 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException
from app.core.database import get_db_connection
from app.core.auth import get_current_admin_user
from app.core.response import create_api_response
from app.core.config import QWEN_API_KEY
from app.services.system_config_service import SystemConfigService
from pydantic import BaseModel
from typing import Optional, List
import json
import dashscope
from dashscope.audio.asr import VocabularyService
from datetime import datetime
@ -14,48 +12,76 @@ from http import HTTPStatus
router = APIRouter()
class HotWordItem(BaseModel):
id: int
text: str
weight: int
lang: str
status: int
create_time: datetime
update_time: datetime
class CreateHotWordRequest(BaseModel):
def _resolve_dashscope_api_key() -> str:
audio_config = SystemConfigService.get_active_audio_model_config("asr") or {}
api_key = str(audio_config.get("api_key") or "").strip()
if not api_key:
raise HTTPException(status_code=500, detail="未在启用的 ASR 模型配置中设置 DashScope API Key")
return api_key
# ── Request Models ──────────────────────────────────────────
class CreateGroupRequest(BaseModel):
name: str
description: Optional[str] = None
status: int = 1
class UpdateGroupRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
status: Optional[int] = None
class CreateItemRequest(BaseModel):
text: str
weight: int = 4
lang: str = "zh"
status: int = 1
class UpdateHotWordRequest(BaseModel):
class UpdateItemRequest(BaseModel):
text: Optional[str] = None
weight: Optional[int] = None
lang: Optional[str] = None
status: Optional[int] = None
@router.get("/admin/hot-words", response_model=dict)
async def list_hot_words(current_user: dict = Depends(get_current_admin_user)):
"""获取热词列表"""
# ── Hot-Word Group CRUD ─────────────────────────────────────
@router.get("/admin/hot-word-groups", response_model=dict)
async def list_groups(current_user: dict = Depends(get_current_admin_user)):
"""列表(含每组热词数量统计)"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM hot_words ORDER BY update_time DESC")
items = cursor.fetchall()
cursor.execute("""
SELECT g.*,
COUNT(i.id) AS item_count,
SUM(CASE WHEN i.status = 1 THEN 1 ELSE 0 END) AS enabled_item_count
FROM hot_word_group g
LEFT JOIN hot_word_item i ON i.group_id = g.id
GROUP BY g.id
ORDER BY g.update_time DESC
""")
groups = cursor.fetchall()
cursor.close()
return create_api_response(code="200", message="获取成功", data=items)
return create_api_response(code="200", message="获取成功", data=groups)
except Exception as e:
return create_api_response(code="500", message=f"获取失败: {str(e)}")
@router.post("/admin/hot-words", response_model=dict)
async def create_hot_word(request: CreateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
"""创建热词"""
@router.post("/admin/hot-word-groups", response_model=dict)
async def create_group(request: CreateGroupRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
query = "INSERT INTO hot_words (text, weight, lang, status) VALUES (%s, %s, %s, %s)"
cursor.execute(query, (request.text, request.weight, request.lang, request.status))
cursor.execute(
"INSERT INTO hot_word_group (name, description, status) VALUES (%s, %s, %s)",
(request.name, request.description, request.status),
)
new_id = cursor.lastrowid
conn.commit()
cursor.close()
@ -63,111 +89,207 @@ async def create_hot_word(request: CreateHotWordRequest, current_user: dict = De
except Exception as e:
return create_api_response(code="500", message=f"创建失败: {str(e)}")
@router.put("/admin/hot-words/{id}", response_model=dict)
async def update_hot_word(id: int, request: UpdateHotWordRequest, current_user: dict = Depends(get_current_admin_user)):
"""更新热词"""
@router.put("/admin/hot-word-groups/{id}", response_model=dict)
async def update_group(id: int, request: UpdateGroupRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
update_fields = []
params = []
if request.text is not None:
update_fields.append("text = %s")
params.append(request.text)
if request.weight is not None:
update_fields.append("weight = %s")
params.append(request.weight)
if request.lang is not None:
update_fields.append("lang = %s")
params.append(request.lang)
fields, params = [], []
if request.name is not None:
fields.append("name = %s"); params.append(request.name)
if request.description is not None:
fields.append("description = %s"); params.append(request.description)
if request.status is not None:
update_fields.append("status = %s")
params.append(request.status)
if not update_fields:
fields.append("status = %s"); params.append(request.status)
if not fields:
return create_api_response(code="400", message="无更新内容")
query = f"UPDATE hot_words SET {', '.join(update_fields)} WHERE id = %s"
params.append(id)
cursor.execute(query, tuple(params))
cursor.execute(f"UPDATE hot_word_group SET {', '.join(fields)} WHERE id = %s", tuple(params))
conn.commit()
cursor.close()
return create_api_response(code="200", message="更新成功")
except Exception as e:
return create_api_response(code="500", message=f"更新失败: {str(e)}")
@router.delete("/admin/hot-words/{id}", response_model=dict)
async def delete_hot_word(id: int, current_user: dict = Depends(get_current_admin_user)):
"""删除热词"""
@router.delete("/admin/hot-word-groups/{id}", response_model=dict)
async def delete_group(id: int, current_user: dict = Depends(get_current_admin_user)):
"""删除组(级联删除条目),同时清除关联的 audio_model_config"""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM hot_words WHERE id = %s", (id,))
# 清除引用该组的音频模型配置
cursor.execute(
"""
UPDATE audio_model_config
SET hot_word_group_id = NULL,
extra_config = JSON_REMOVE(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id')
WHERE hot_word_group_id = %s
""",
(id,),
)
cursor.execute("DELETE FROM hot_word_item WHERE group_id = %s", (id,))
cursor.execute("DELETE FROM hot_word_group WHERE id = %s", (id,))
conn.commit()
cursor.close()
return create_api_response(code="200", message="删除成功")
except Exception as e:
return create_api_response(code="500", message=f"删除失败: {str(e)}")
@router.post("/admin/hot-words/sync", response_model=dict)
async def sync_hot_words(current_user: dict = Depends(get_current_admin_user)):
"""同步热词到阿里云 DashScope"""
try:
dashscope.api_key = QWEN_API_KEY
# 1. 获取所有启用的热词
@router.post("/admin/hot-word-groups/{id}/sync", response_model=dict)
async def sync_group(id: int, current_user: dict = Depends(get_current_admin_user)):
"""同步指定组到阿里云 DashScope"""
try:
dashscope.api_key = _resolve_dashscope_api_key()
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT text, weight, lang FROM hot_words WHERE status = 1")
hot_words = cursor.fetchall()
cursor.close()
# 2. 获取现有的 vocabulary_id
existing_vocab_id = SystemConfigService.get_asr_vocabulary_id()
# 获取组信息
cursor.execute("SELECT * FROM hot_word_group WHERE id = %s", (id,))
group = cursor.fetchone()
if not group:
return create_api_response(code="404", message="热词组不存在")
# 构建热词列表
vocabulary_list = [{"text": hw['text'], "weight": hw['weight'], "lang": hw['lang']} for hw in hot_words]
# 获取该组下启用的热词
cursor.execute(
"SELECT text, weight, lang FROM hot_word_item WHERE group_id = %s AND status = 1",
(id,),
)
items = cursor.fetchall()
if not items:
return create_api_response(code="400", message="该组没有启用的热词可同步")
if not vocabulary_list:
return create_api_response(code="400", message="没有启用的热词可同步")
vocabulary_list = [{"text": it["text"], "weight": it["weight"], "lang": it["lang"]} for it in items]
# ASR 模型名(同步时需要)
asr_model_name = SystemConfigService.get_config_attribute('audio_model', 'model', 'paraformer-v2')
existing_vocab_id = group.get("vocabulary_id")
# 3. 调用阿里云 API
service = VocabularyService()
vocab_id = existing_vocab_id
try:
if existing_vocab_id:
# 尝试更新现有的热词表
try:
service.update_vocabulary(
vocabulary_id=existing_vocab_id,
vocabulary=vocabulary_list
vocabulary=vocabulary_list,
)
# 更新成功保持原有ID
except Exception as update_error:
# 如果更新失败(如资源不存在),尝试创建新的
print(f"Update vocabulary failed: {update_error}, trying to create new one.")
existing_vocab_id = None # 重置,触发创建逻辑
except Exception:
existing_vocab_id = None # 更新失败,重建
if not existing_vocab_id:
# 创建新的热词表
vocab_id = service.create_vocabulary(
prefix='imeeting',
target_model='paraformer-v2',
vocabulary=vocabulary_list
prefix="imeeting",
target_model=asr_model_name,
vocabulary=vocabulary_list,
)
except Exception as api_error:
return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}")
return create_api_response(code="500", message=f"同步到阿里云失败: {str(api_error)}")
# 4. 更新数据库中的 vocabulary_id
if vocab_id:
SystemConfigService.set_config(
SystemConfigService.ASR_VOCABULARY_ID,
vocab_id
)
# 回写 vocabulary_id 到热词组
cursor.execute(
"UPDATE hot_word_group SET vocabulary_id = %s, last_sync_time = NOW() WHERE id = %s",
(vocab_id, id),
)
return create_api_response(code="200", message="同步成功", data={"vocabulary_id": vocab_id})
# 更新关联该组的所有 audio_model_config.extra_config.vocabulary_id
cursor.execute(
"""
UPDATE audio_model_config
SET extra_config = JSON_SET(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id', %s)
WHERE hot_word_group_id = %s
""",
(vocab_id, id),
)
conn.commit()
cursor.close()
return create_api_response(
code="200",
message="同步成功",
data={"vocabulary_id": vocab_id, "synced_count": len(vocabulary_list)},
)
except Exception as e:
return create_api_response(code="500", message=f"同步异常: {str(e)}")
# ── Hot-Word Item CRUD ──────────────────────────────────────
@router.get("/admin/hot-word-groups/{group_id}/items", response_model=dict)
async def list_items(group_id: int, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT * FROM hot_word_item WHERE group_id = %s ORDER BY update_time DESC",
(group_id,),
)
items = cursor.fetchall()
cursor.close()
return create_api_response(code="200", message="获取成功", data=items)
except Exception as e:
return create_api_response(code="500", message=f"获取失败: {str(e)}")
@router.post("/admin/hot-word-groups/{group_id}/items", response_model=dict)
async def create_item(group_id: int, request: CreateItemRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO hot_word_item (group_id, text, weight, lang, status) VALUES (%s, %s, %s, %s, %s)",
(group_id, request.text, request.weight, request.lang, request.status),
)
new_id = cursor.lastrowid
conn.commit()
cursor.close()
return create_api_response(code="200", message="创建成功", data={"id": new_id})
except Exception as e:
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="该组内已存在相同热词")
return create_api_response(code="500", message=f"创建失败: {str(e)}")
@router.put("/admin/hot-word-items/{id}", response_model=dict)
async def update_item(id: int, request: UpdateItemRequest, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
fields, params = [], []
if request.text is not None:
fields.append("text = %s"); params.append(request.text)
if request.weight is not None:
fields.append("weight = %s"); params.append(request.weight)
if request.lang is not None:
fields.append("lang = %s"); params.append(request.lang)
if request.status is not None:
fields.append("status = %s"); params.append(request.status)
if not fields:
return create_api_response(code="400", message="无更新内容")
params.append(id)
cursor.execute(f"UPDATE hot_word_item SET {', '.join(fields)} WHERE id = %s", tuple(params))
conn.commit()
cursor.close()
return create_api_response(code="200", message="更新成功")
except Exception as e:
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="该组内已存在相同热词")
return create_api_response(code="500", message=f"更新失败: {str(e)}")
@router.delete("/admin/hot-word-items/{id}", response_model=dict)
async def delete_item(id: int, current_user: dict = Depends(get_current_admin_user)):
try:
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM hot_word_item WHERE id = %s", (id,))
conn.commit()
cursor.close()
return create_api_response(code="200", message="删除成功")
except Exception as e:
return create_api_response(code="500", message=f"删除失败: {str(e)}")

View File

@ -41,7 +41,7 @@ def get_knowledge_bases(
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
base_query = "FROM knowledge_bases kb JOIN users u ON kb.creator_id = u.user_id"
base_query = "FROM knowledge_bases kb JOIN sys_users u ON kb.creator_id = u.user_id"
where_clauses = []
params = []
@ -156,7 +156,7 @@ def get_knowledge_base_detail(
kb.is_shared, kb.source_meeting_ids, kb.user_prompt, kb.tags, kb.created_at, kb.updated_at,
u.username as created_by_name
FROM knowledge_bases kb
JOIN users u ON kb.creator_id = u.user_id
JOIN sys_users u ON kb.creator_id = u.user_id
WHERE kb.kb_id = %s
"""
cursor.execute(query, (kb_id,))

File diff suppressed because it is too large Load Diff

View File

@ -1,240 +1,383 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import List, Optional
from typing import Optional, List
from app.core.auth import get_current_user
from app.core.database import get_db_connection
from app.core.response import create_api_response
from app.models.models import PromptCreate, PromptUpdate
router = APIRouter()
# Pydantic Models
class PromptIn(BaseModel):
name: str
task_type: str # 'MEETING_TASK' 或 'KNOWLEDGE_TASK'
content: str
is_default: bool = False
is_active: bool = True
class PromptOut(PromptIn):
id: int
creator_id: int
created_at: str
class PromptConfigItem(BaseModel):
prompt_id: int
is_enabled: bool = True
sort_order: int = 0
class PromptConfigUpdateRequest(BaseModel):
items: List[PromptConfigItem]
def _is_admin(user: dict) -> bool:
return int(user.get("role_id") or 0) == 1
def _can_manage_prompt(current_user: dict, row: dict) -> bool:
if _is_admin(current_user):
return True
return int(row.get("creator_id") or 0) == int(current_user["user_id"]) and int(row.get("is_system") or 0) == 0
class PromptListResponse(BaseModel):
prompts: List[PromptOut]
total: int
@router.post("/prompts")
def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_user)):
"""Create a new prompt."""
def create_prompt(
prompt: PromptCreate,
current_user: dict = Depends(get_current_user),
):
"""Create a prompt template. Admin can create system prompts, others can only create personal prompts."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
try:
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
if prompt.is_default:
is_admin = _is_admin(current_user)
requested_is_system = bool(getattr(prompt, "is_system", False))
is_system = 1 if (is_admin and requested_is_system) else 0
owner_user_id = current_user["user_id"]
cursor.execute(
"""
SELECT COUNT(*) as cnt
FROM prompts
WHERE task_type = %s
AND is_system = %s
AND creator_id = %s
""",
(prompt.task_type, is_system, owner_user_id),
)
count = (cursor.fetchone() or {}).get("cnt", 0)
is_default = 1 if count == 0 else (1 if prompt.is_default else 0)
if is_default:
cursor.execute(
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s",
(prompt.task_type,)
"""
UPDATE prompts
SET is_default = 0
WHERE task_type = %s
AND is_system = %s
AND creator_id = %s
""",
(prompt.task_type, is_system, owner_user_id),
)
cursor.execute(
"""INSERT INTO prompts (name, task_type, content, is_default, is_active, creator_id)
VALUES (%s, %s, %s, %s, %s, %s)""",
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
prompt.is_active, current_user["user_id"])
"""
INSERT INTO prompts (name, task_type, content, `desc`, is_default, is_active, creator_id, is_system)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
prompt.name,
prompt.task_type,
prompt.content,
prompt.desc,
is_default,
1 if prompt.is_active else 0,
owner_user_id,
is_system,
),
)
prompt_id = cursor.lastrowid
connection.commit()
new_id = cursor.lastrowid
return create_api_response(
code="200",
message="提示词创建成功",
data={"id": new_id, **prompt.dict()}
)
return create_api_response(code="200", message="提示词模版创建成功", data={"id": prompt_id})
except Exception as e:
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="提示词名称已存在")
return create_api_response(code="500", message=f"创建提示词失败: {e}")
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prompts/active/{task_type}")
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)):
"""Get all active prompts for a specific task type."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""SELECT id, name, is_default
FROM prompts
WHERE task_type = %s AND is_active = TRUE
ORDER BY is_default DESC, created_at DESC""",
(task_type,)
)
prompts = cursor.fetchall()
return create_api_response(
code="200",
message="获取启用模版列表成功",
data={"prompts": prompts}
)
@router.get("/prompts")
def get_prompts(
task_type: Optional[str] = None,
page: int = 1,
size: int = 50,
current_user: dict = Depends(get_current_user)
size: int = 12,
keyword: Optional[str] = Query(None),
is_active: Optional[int] = Query(None),
scope: str = Query("mine"), # mine / system / all / accessible
current_user: dict = Depends(get_current_user),
):
"""Get a paginated list of prompts filtered by current user and optionally by task_type."""
"""Get paginated prompt cards. Normal users can only view their own prompts."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 构建 WHERE 条件
where_conditions = ["creator_id = %s"]
params = [current_user["user_id"]]
is_admin = _is_admin(current_user)
where_conditions = []
params = []
if scope == "all" and not is_admin:
scope = "accessible"
if scope == "system":
where_conditions.append("p.is_system = 1")
elif scope == "all":
where_conditions.append("(p.is_system = 1 OR p.creator_id = %s)")
params.append(current_user["user_id"])
elif scope == "accessible":
where_conditions.append("((p.is_system = 1 AND p.is_active = 1) OR (p.is_system = 0 AND p.creator_id = %s))")
params.append(current_user["user_id"])
else:
where_conditions.append("p.is_system = 0 AND p.creator_id = %s")
params.append(current_user["user_id"])
if task_type:
where_conditions.append("task_type = %s")
where_conditions.append("p.task_type = %s")
params.append(task_type)
where_clause = " AND ".join(where_conditions)
if keyword:
where_conditions.append("(p.name LIKE %s OR p.`desc` LIKE %s)")
like = f"%{keyword}%"
params.extend([like, like])
if is_active in (0, 1):
where_conditions.append("p.is_active = %s")
params.append(is_active)
# 获取总数
cursor.execute(
f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}",
tuple(params)
)
total = cursor.fetchone()['total']
where_clause = " AND ".join(where_conditions) if where_conditions else "1=1"
# 获取分页数据
offset = (page - 1) * size
cursor.execute(f"SELECT COUNT(*) as total FROM prompts p WHERE {where_clause}", tuple(params))
total = (cursor.fetchone() or {}).get("total", 0)
offset = max(page - 1, 0) * size
cursor.execute(
f"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
FROM prompts
WHERE {where_clause}
ORDER BY created_at DESC
LIMIT %s OFFSET %s""",
tuple(params + [size, offset])
f"""
SELECT p.id, p.name, p.task_type, p.content, p.`desc`, p.is_default, p.is_active,
p.creator_id, p.is_system, p.created_at,
u.caption AS creator_name
FROM prompts p
LEFT JOIN sys_users u ON u.user_id = p.creator_id
WHERE {where_clause}
ORDER BY p.is_system DESC, p.task_type ASC, p.is_default DESC, p.created_at DESC
LIMIT %s OFFSET %s
""",
tuple(params + [size, offset]),
)
prompts = cursor.fetchall()
rows = cursor.fetchall()
return create_api_response(
code="200",
message="获取提示词列表成功",
data={"prompts": prompts, "total": total}
data={"prompts": rows, "total": total, "page": page, "size": size},
)
@router.get("/prompts/{prompt_id}")
def get_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
"""Get a single prompt by its ID."""
@router.get("/prompts/active/{task_type}")
def get_active_prompts(task_type: str, current_user: dict = Depends(get_current_user)):
"""
Active prompts for task selection.
Includes system prompts + personal prompts, and applies user's prompt config ordering.
"""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at
FROM prompts WHERE id = %s""",
(prompt_id,)
"""
SELECT p.id, p.name, p.`desc`, p.content, p.is_default, p.is_system, p.creator_id,
cfg.is_enabled, cfg.sort_order
FROM prompts p
LEFT JOIN prompt_config cfg
ON cfg.prompt_id = p.id
AND cfg.user_id = %s
AND cfg.task_type = %s
WHERE p.task_type = %s
AND p.is_active = 1
AND (p.is_system = 1 OR p.creator_id = %s)
ORDER BY
CASE WHEN cfg.is_enabled = 1 THEN 0 ELSE 1 END,
cfg.sort_order ASC,
p.is_default DESC,
p.created_at DESC
""",
(current_user["user_id"], task_type, task_type, current_user["user_id"]),
)
prompt = cursor.fetchone()
if not prompt:
return create_api_response(code="404", message="提示词不存在")
return create_api_response(code="200", message="获取提示词成功", data=prompt)
prompts = cursor.fetchall()
@router.put("/prompts/{prompt_id}")
def update_prompt(prompt_id: int, prompt: PromptIn, current_user: dict = Depends(get_current_user)):
"""Update an existing prompt."""
print(f"[UPDATE PROMPT] prompt_id={prompt_id}, type={type(prompt_id)}")
print(f"[UPDATE PROMPT] user_id={current_user['user_id']}")
print(f"[UPDATE PROMPT] data: name={prompt.name}, task_type={prompt.task_type}, content_len={len(prompt.content)}, is_default={prompt.is_default}, is_active={prompt.is_active}")
enabled = [x for x in prompts if x.get("is_enabled") == 1]
if enabled:
result = enabled
else:
result = prompts
return create_api_response(code="200", message="获取启用模版列表成功", data={"prompts": result})
@router.get("/prompts/config/{task_type}")
def get_prompt_config(task_type: str, current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT id, name, task_type, content, `desc`, is_default, is_active, is_system, creator_id, created_at
FROM prompts
WHERE task_type = %s
AND is_active = 1
AND (is_system = 1 OR creator_id = %s)
ORDER BY is_system DESC, is_default DESC, created_at DESC
""",
(task_type, current_user["user_id"]),
)
available = cursor.fetchall()
cursor.execute(
"""
SELECT prompt_id, is_enabled, sort_order
FROM prompt_config
WHERE user_id = %s AND task_type = %s
ORDER BY sort_order ASC, config_id ASC
""",
(current_user["user_id"], task_type),
)
configs = cursor.fetchall()
selected_prompt_ids = [item["prompt_id"] for item in configs if item.get("is_enabled") == 1]
return create_api_response(
code="200",
message="获取提示词配置成功",
data={
"task_type": task_type,
"available_prompts": available,
"configs": configs,
"selected_prompt_ids": selected_prompt_ids,
},
)
@router.put("/prompts/config/{task_type}")
def update_prompt_config(
task_type: str,
request: PromptConfigUpdateRequest,
current_user: dict = Depends(get_current_user),
):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
try:
# 先检查记录是否存在
cursor.execute("SELECT id, creator_id FROM prompts WHERE id = %s", (prompt_id,))
existing = cursor.fetchone()
print(f"[UPDATE PROMPT] existing record: {existing}")
if not existing:
print(f"[UPDATE PROMPT] Prompt {prompt_id} not found in database")
return create_api_response(code="404", message="提示词不存在")
# 如果设置为默认,需要先取消同类型其他提示词的默认状态
if prompt.is_default:
print(f"[UPDATE PROMPT] Setting as default, clearing other defaults for task_type={prompt.task_type}")
requested_ids = [int(item.prompt_id) for item in request.items if item.is_enabled]
if requested_ids:
placeholders = ",".join(["%s"] * len(requested_ids))
cursor.execute(
"UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s",
(prompt.task_type, prompt_id)
f"""
SELECT id
FROM prompts
WHERE id IN ({placeholders})
AND task_type = %s
AND is_active = 1
AND (is_system = 1 OR creator_id = %s)
""",
tuple(requested_ids + [task_type, current_user["user_id"]]),
)
print(f"[UPDATE PROMPT] Cleared {cursor.rowcount} other default prompts")
valid_ids = {row["id"] for row in cursor.fetchall()}
invalid_ids = [pid for pid in requested_ids if pid not in valid_ids]
if invalid_ids:
raise HTTPException(status_code=400, detail=f"存在无效提示词ID: {invalid_ids}")
print(f"[UPDATE PROMPT] Executing UPDATE query")
cursor.execute(
"""UPDATE prompts
SET name = %s, task_type = %s, content = %s, is_default = %s, is_active = %s
WHERE id = %s""",
(prompt.name, prompt.task_type, prompt.content, prompt.is_default,
prompt.is_active, prompt_id)
"DELETE FROM prompt_config WHERE user_id = %s AND task_type = %s",
(current_user["user_id"], task_type),
)
rows_affected = cursor.rowcount
print(f"[UPDATE PROMPT] UPDATE affected {rows_affected} rows (0 means no changes needed)")
# 注意rowcount=0 不代表记录不存在,可能是所有字段值都相同
# 我们已经在上面确认了记录存在,所以这里直接提交即可
ordered = sorted(
[item for item in request.items if item.is_enabled],
key=lambda x: (x.sort_order, x.prompt_id),
)
for idx, item in enumerate(ordered):
cursor.execute(
"""
INSERT INTO prompt_config (user_id, task_type, prompt_id, is_enabled, sort_order)
VALUES (%s, %s, %s, 1, %s)
""",
(current_user["user_id"], task_type, int(item.prompt_id), idx + 1),
)
connection.commit()
print(f"[UPDATE PROMPT] Success! Committed changes")
return create_api_response(code="200", message="提示词更新成功")
return create_api_response(code="200", message="提示词配置保存成功")
except HTTPException:
connection.rollback()
raise
except Exception as e:
print(f"[UPDATE PROMPT] Exception: {type(e).__name__}: {e}")
if "Duplicate entry" in str(e):
return create_api_response(code="400", message="提示词名称已存在")
return create_api_response(code="500", message=f"更新提示词失败: {e}")
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/prompts/{prompt_id}")
def update_prompt(prompt_id: int, prompt: PromptUpdate, current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
try:
cursor.execute("SELECT id, creator_id, task_type, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
existing = cursor.fetchone()
if not existing:
raise HTTPException(status_code=404, detail="模版不存在")
if not _can_manage_prompt(current_user, existing):
raise HTTPException(status_code=403, detail="无权修改此模版")
if prompt.is_default is False and existing["is_default"]:
raise HTTPException(status_code=400, detail="必须保留一个默认模版,请先设置其他模版为默认")
if prompt.is_system is not None and not _is_admin(current_user):
raise HTTPException(status_code=403, detail="普通用户不能修改系统提示词属性")
if prompt.is_default:
task_type = prompt.task_type or existing["task_type"]
cursor.execute(
"""
UPDATE prompts
SET is_default = 0
WHERE task_type = %s
AND is_system = %s
AND creator_id = %s
""",
(task_type, existing.get("is_system", 0), existing["creator_id"]),
)
if prompt.is_active is False:
raise HTTPException(status_code=400, detail="默认模版必须处于启用状态")
update_fields = []
params = []
prompt_data = prompt.dict(exclude_unset=True)
for field, value in prompt_data.items():
if field == "desc":
update_fields.append("`desc` = %s")
else:
update_fields.append(f"{field} = %s")
params.append(value)
if update_fields:
params.append(prompt_id)
cursor.execute(f"UPDATE prompts SET {', '.join(update_fields)} WHERE id = %s", tuple(params))
connection.commit()
return create_api_response(code="200", message="更新成功")
except HTTPException:
raise
except Exception as e:
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prompts/{prompt_id}")
def delete_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)):
"""Delete a prompt. Only the creator can delete their own prompts."""
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
# 首先检查提示词是否存在以及是否属于当前用户
cursor.execute(
"SELECT creator_id FROM prompts WHERE id = %s",
(prompt_id,)
)
prompt = cursor.fetchone()
try:
cursor.execute("SELECT id, creator_id, is_default, is_system FROM prompts WHERE id = %s", (prompt_id,))
existing = cursor.fetchone()
if not existing:
raise HTTPException(status_code=404, detail="模版不存在")
if not _can_manage_prompt(current_user, existing):
raise HTTPException(status_code=403, detail="无权删除此模版")
if existing["is_default"]:
raise HTTPException(status_code=400, detail="默认模版不允许删除,请先设置其他模版为默认")
if not prompt:
return create_api_response(code="404", message="提示词不存在")
if prompt['creator_id'] != current_user["user_id"]:
return create_api_response(code="403", message="无权删除其他用户的提示词")
# 检查是否有会议引用了该提示词
cursor.execute(
"SELECT COUNT(*) as count FROM meetings WHERE prompt_id = %s",
(prompt_id,)
)
meeting_count = cursor.fetchone()['count']
# 检查是否有知识库引用了该提示词
cursor.execute(
"SELECT COUNT(*) as count FROM knowledge_bases WHERE prompt_id = %s",
(prompt_id,)
)
kb_count = cursor.fetchone()['count']
# 如果有引用,不允许删除
if meeting_count > 0 or kb_count > 0:
references = []
if meeting_count > 0:
references.append(f"{meeting_count}个会议")
if kb_count > 0:
references.append(f"{kb_count}个知识库")
return create_api_response(
code="400",
message=f"无法删除:该提示词被{''.join(references)}引用",
data={
"meeting_count": meeting_count,
"kb_count": kb_count
}
)
# 删除提示词
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
connection.commit()
return create_api_response(code="200", message="提示词删除成功")
cursor.execute("DELETE FROM prompts WHERE id = %s", (prompt_id,))
connection.commit()
return create_api_response(code="200", message="删除成功")
except HTTPException:
raise
except Exception as e:
connection.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, UploadFile, File
from typing import Optional
from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo
from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo, UserMcpInfo
from app.core.database import get_db_connection
from app.core.auth import get_current_user
from app.core.response import create_api_response
@ -13,6 +13,7 @@ import re
import os
import shutil
import uuid
import secrets
from pathlib import Path
router = APIRouter()
@ -25,6 +26,59 @@ def validate_email(email: str) -> bool:
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def _generate_mcp_bot_id() -> str:
return f"nexbot_{secrets.token_hex(8)}"
def _generate_mcp_bot_secret() -> str:
random_part = secrets.token_urlsafe(24).replace('-', '').replace('_', '')
return f"nxbotsec_{random_part}"
def _get_user_mcp_record(cursor, user_id: int):
cursor.execute(
"""
SELECT id, user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at
FROM sys_user_mcp
WHERE user_id = %s
""",
(user_id,),
)
return cursor.fetchone()
def _ensure_user_exists(cursor, user_id: int) -> bool:
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
return cursor.fetchone() is not None
def _serialize_user_mcp(record: dict) -> dict:
return UserMcpInfo(**record).dict()
def _ensure_user_mcp_record(connection, cursor, user_id: int):
record = _get_user_mcp_record(cursor, user_id)
if record:
return record
bot_id = _generate_mcp_bot_id()
while True:
cursor.execute("SELECT id FROM sys_user_mcp WHERE bot_id = %s", (bot_id,))
if not cursor.fetchone():
break
bot_id = _generate_mcp_bot_id()
cursor.execute(
"""
INSERT INTO sys_user_mcp (user_id, bot_id, bot_secret, status, last_used_at, created_at, updated_at)
VALUES (%s, %s, %s, 1, NULL, NOW(), NOW())
""",
(user_id, bot_id, _generate_mcp_bot_secret()),
)
connection.commit()
return _get_user_mcp_record(cursor, user_id)
@router.get("/roles")
def get_all_roles(current_user: dict = Depends(get_current_user)):
"""获取所有角色列表"""
@ -33,7 +87,7 @@ def get_all_roles(current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT role_id, role_name FROM roles ORDER BY role_id")
cursor.execute("SELECT role_id, role_name FROM sys_roles ORDER BY role_id")
roles = cursor.fetchall()
return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles])
@ -42,20 +96,20 @@ def create_user(request: CreateUserRequest, current_user: dict = Depends(get_cur
if current_user['role_id'] != 1: # 1 is admin
return create_api_response(code="403", message="仅管理员有权限创建用户")
if not validate_email(request.email):
if request.email and not validate_email(request.email):
return create_api_response(code="400", message="邮箱格式不正确")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM users WHERE username = %s", (request.username,))
cursor.execute("SELECT user_id FROM sys_users WHERE username = %s", (request.username,))
if cursor.fetchone():
return create_api_response(code="400", message="用户名已存在")
password = request.password if request.password else SystemConfigService.get_default_reset_password()
hashed_password = hash_password(password)
query = "INSERT INTO users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)"
query = "INSERT INTO sys_users (username, password_hash, caption, email, avatar_url, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)"
created_at = datetime.datetime.utcnow()
cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.avatar_url, request.role_id, created_at))
connection.commit()
@ -74,13 +128,13 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT user_id, username, caption, email, avatar_url, role_id FROM sys_users WHERE user_id = %s", (user_id,))
existing_user = cursor.fetchone()
if not existing_user:
return create_api_response(code="404", message="用户不存在")
if request.username and request.username != existing_user['username']:
cursor.execute("SELECT user_id FROM users WHERE username = %s AND user_id != %s", (request.username, user_id))
cursor.execute("SELECT user_id FROM sys_users WHERE username = %s AND user_id != %s", (request.username, user_id))
if cursor.fetchone():
return create_api_response(code="400", message="用户名已存在")
@ -97,14 +151,14 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
'role_id': target_role_id
}
query = "UPDATE users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s"
query = "UPDATE sys_users SET username = %s, caption = %s, email = %s, avatar_url = %s, role_id = %s WHERE user_id = %s"
cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['avatar_url'], update_data['role_id'], user_id))
connection.commit()
cursor.execute('''
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.role_id
FROM sys_users u
LEFT JOIN sys_roles r ON u.role_id = r.role_id
WHERE u.user_id = %s
''', (user_id,))
updated_user = cursor.fetchone()
@ -117,9 +171,7 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D
avatar_url=updated_user['avatar_url'],
created_at=updated_user['created_at'],
role_id=updated_user['role_id'],
role_name=updated_user['role_name'],
meetings_created=0,
meetings_attended=0
role_name=updated_user['role_name'] or '普通用户'
)
return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict())
@ -131,11 +183,11 @@ def delete_user(user_id: int, current_user: dict = Depends(get_current_user)):
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="用户不存在")
cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,))
cursor.execute("DELETE FROM sys_users WHERE user_id = %s", (user_id,))
connection.commit()
return create_api_response(code="200", message="用户删除成功")
@ -148,13 +200,13 @@ def reset_password(user_id: int, current_user: dict = Depends(get_current_user))
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT user_id FROM sys_users WHERE user_id = %s", (user_id,))
if not cursor.fetchone():
return create_api_response(code="404", message="用户不存在")
hashed_password = hash_password(SystemConfigService.get_default_reset_password())
query = "UPDATE users SET password_hash = %s WHERE user_id = %s"
query = "UPDATE sys_users SET password_hash = %s WHERE user_id = %s"
cursor.execute(query, (hashed_password, user_id))
connection.commit()
@ -185,7 +237,7 @@ def get_all_users(
count_params.extend([search_pattern, search_pattern])
# 统计查询
count_query = "SELECT COUNT(*) as total FROM users u"
count_query = "SELECT COUNT(*) as total FROM sys_users u"
if where_conditions:
count_query += " WHERE " + " AND ".join(where_conditions)
@ -197,12 +249,16 @@ def get_all_users(
# 主查询
query = '''
SELECT
u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
r.role_name,
(SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created,
(SELECT COUNT(*) FROM attendees WHERE user_id = u.user_id) as meetings_attended
FROM users u
LEFT JOIN roles r ON u.role_id = r.role_id
u.user_id,
u.username,
u.caption,
u.email,
u.avatar_url,
u.created_at,
u.role_id,
COALESCE(r.role_name, '普通用户') AS role_name
FROM sys_users u
LEFT JOIN sys_roles r ON u.role_id = r.role_id
'''
query_params = []
@ -231,9 +287,10 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
cursor = connection.cursor(dictionary=True)
user_query = '''
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id, r.role_name
FROM users u
LEFT JOIN roles r ON u.role_id = r.role_id
SELECT u.user_id, u.username, u.caption, u.email, u.avatar_url, u.created_at, u.role_id,
COALESCE(r.role_name, '普通用户') AS role_name
FROM sys_users u
LEFT JOIN sys_roles r ON u.role_id = r.role_id
WHERE u.user_id = %s
'''
cursor.execute(user_query, (user_id,))
@ -242,14 +299,6 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
if not user:
return create_api_response(code="404", message="用户不存在")
created_query = "SELECT COUNT(*) as count FROM meetings WHERE user_id = %s"
cursor.execute(created_query, (user_id,))
meetings_created = cursor.fetchone()['count']
attended_query = "SELECT COUNT(*) as count FROM attendees WHERE user_id = %s"
cursor.execute(attended_query, (user_id,))
meetings_attended = cursor.fetchone()['count']
user_info = UserInfo(
user_id=user['user_id'],
username=user['username'],
@ -258,9 +307,7 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)):
avatar_url=user['avatar_url'],
created_at=user['created_at'],
role_id=user['role_id'],
role_name=user['role_name'],
meetings_created=meetings_created,
meetings_attended=meetings_attended
role_name=user['role_name']
)
return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict())
@ -272,7 +319,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT password_hash FROM users WHERE user_id = %s", (user_id,))
cursor.execute("SELECT password_hash FROM sys_users WHERE user_id = %s", (user_id,))
user = cursor.fetchone()
if not user:
@ -283,7 +330,7 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user:
return create_api_response(code="400", message="旧密码错误")
new_password_hash = hash_password(request.new_password)
cursor.execute("UPDATE users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id))
cursor.execute("UPDATE sys_users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id))
connection.commit()
return create_api_response(code="200", message="密码修改成功")
@ -305,7 +352,7 @@ def upload_user_avatar(
return create_api_response(code="400", message="不支持的文件类型")
# Ensure upload directory exists: AVATAR_DIR / str(user_id)
user_avatar_dir = AVATAR_DIR / str(user_id)
user_avatar_dir = config_module.get_user_avatar_dir(user_id)
if not user_avatar_dir.exists():
os.makedirs(user_avatar_dir)
@ -321,13 +368,57 @@ def upload_user_avatar(
# AVATAR_DIR is uploads/user/avatar
# file path is uploads/user/avatar/{user_id}/{filename}
# URL should be /uploads/user/avatar/{user_id}/{filename}
avatar_url = f"/uploads/user/avatar/{user_id}/{unique_filename}"
avatar_url = f"/uploads/user/{user_id}/avatar/{unique_filename}"
# Update database
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("UPDATE users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id))
cursor.execute("UPDATE sys_users SET avatar_url = %s WHERE user_id = %s", (avatar_url, user_id))
connection.commit()
return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url})
return create_api_response(code="200", message="头像上传成功", data={"avatar_url": avatar_url})
@router.get("/users/{user_id}/mcp-config")
def get_user_mcp_config(user_id: int, current_user: dict = Depends(get_current_user)):
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
return create_api_response(code="403", message="没有权限查看该用户的MCP配置")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
if not _ensure_user_exists(cursor, user_id):
return create_api_response(code="404", message="用户不存在")
record = _ensure_user_mcp_record(connection, cursor, user_id)
return create_api_response(code="200", message="获取MCP配置成功", data=_serialize_user_mcp(record))
@router.post("/users/{user_id}/mcp-config/regenerate")
def regenerate_user_mcp_secret(user_id: int, current_user: dict = Depends(get_current_user)):
if current_user['role_id'] != 1 and current_user['user_id'] != user_id:
return create_api_response(code="403", message="没有权限更新该用户的MCP配置")
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
if not _ensure_user_exists(cursor, user_id):
return create_api_response(code="404", message="用户不存在")
record = _get_user_mcp_record(cursor, user_id)
if not record:
record = _ensure_user_mcp_record(connection, cursor, user_id)
else:
cursor.execute(
"""
UPDATE sys_user_mcp
SET bot_secret = %s, status = 1, updated_at = NOW()
WHERE user_id = %s
""",
(_generate_mcp_bot_secret(), user_id),
)
connection.commit()
record = _get_user_mcp_record(cursor, user_id)
return create_api_response(code="200", message="MCP Secret 已重新生成", data=_serialize_user_mcp(record))

View File

@ -25,12 +25,12 @@ def get_voiceprint_template(current_user: dict = Depends(get_current_user)):
"""
try:
template_data = VoiceprintTemplate(
template_text=SystemConfigService.get_voiceprint_template(),
content=SystemConfigService.get_voiceprint_template(),
duration_seconds=SystemConfigService.get_voiceprint_duration(),
sample_rate=SystemConfigService.get_voiceprint_sample_rate(),
channels=SystemConfigService.get_voiceprint_channels()
)
return create_api_response(code="200", message="获取朗读模板成功", data=template_data.dict())
return create_api_response(code="200", message="获取朗读模板成功", data=template_data.model_dump())
except Exception as e:
return create_api_response(code="500", message=f"获取朗读模板失败: {str(e)}")
@ -89,7 +89,7 @@ async def upload_voiceprint(
try:
# 确保用户目录存在
user_voiceprint_dir = config_module.VOICEPRINT_DIR / str(user_id)
user_voiceprint_dir = config_module.get_user_voiceprint_dir(user_id)
user_voiceprint_dir.mkdir(parents=True, exist_ok=True)
# 生成文件名:时间戳.wav

View File

@ -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

View File

@ -24,7 +24,7 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s",
"SELECT user_id, username, caption, email, role_id FROM sys_users WHERE user_id = %s",
(user_id,)
)
user = cursor.fetchone()
@ -67,7 +67,7 @@ def get_optional_current_user(request: Request) -> Optional[dict]:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"SELECT user_id, username, caption, email FROM users WHERE user_id = %s",
"SELECT user_id, username, caption, email FROM sys_users WHERE user_id = %s",
(user_id,)
)
return cursor.fetchone()

View File

@ -3,12 +3,9 @@ import json
from pathlib import Path
from dotenv import load_dotenv
# 加载 .env 文件
env_path = Path(__file__).parent.parent.parent / ".env"
load_dotenv(dotenv_path=env_path)
# 基础路径配置
BASE_DIR = Path(__file__).parent.parent.parent
REPO_DIR = BASE_DIR.parent
UPLOAD_DIR = BASE_DIR / "uploads"
AUDIO_DIR = UPLOAD_DIR / "audio"
TEMP_UPLOAD_DIR = UPLOAD_DIR / "temp_audio"
@ -16,8 +13,63 @@ MARKDOWN_DIR = UPLOAD_DIR / "markdown"
CLIENT_DIR = UPLOAD_DIR / "clients"
EXTERNAL_APPS_DIR = UPLOAD_DIR / "external_apps"
USER_DIR = UPLOAD_DIR / "user"
VOICEPRINT_DIR = USER_DIR / "voiceprint"
AVATAR_DIR = USER_DIR / "avatar"
LEGACY_VOICEPRINT_DIR = USER_DIR / "voiceprint"
LEGACY_AVATAR_DIR = USER_DIR / "avatar"
VOICEPRINT_DIR = USER_DIR
AVATAR_DIR = USER_DIR
def _is_running_in_docker() -> bool:
return Path("/.dockerenv").exists()
def _load_env_file(dotenv_path: Path) -> None:
if dotenv_path.exists():
load_dotenv(dotenv_path=dotenv_path, override=False)
# 非 Docker 本地运行时,兼容读取仓库根目录 .env 与 backend/.env
# Docker 容器内只使用显式注入的环境变量,避免意外读取镜像内的开发配置。
if not _is_running_in_docker():
_load_env_file(REPO_DIR / ".env")
_load_env_file(BASE_DIR / ".env")
def _get_env(*names: str, default: str | None = None, allow_blank: bool = True) -> str | None:
for name in names:
value = os.getenv(name)
if value is None:
continue
value = value.strip() if isinstance(value, str) else value
if value == "" and not allow_blank:
continue
return value
return default
def _get_int_env(*names: str, default: int) -> int:
raw_value = _get_env(*names, default=str(default), allow_blank=False)
try:
return int(raw_value) if raw_value is not None else default
except (TypeError, ValueError):
return default
def _normalize_base_url(value: str | None) -> str:
normalized = str(value or "").strip().rstrip("/")
return normalized or "http://localhost"
def get_user_data_dir(user_id: int | str) -> Path:
return USER_DIR / str(user_id)
def get_user_voiceprint_dir(user_id: int | str) -> Path:
return get_user_data_dir(user_id) / "voiceprint"
def get_user_avatar_dir(user_id: int | str) -> Path:
return get_user_data_dir(user_id) / "avatar"
# 文件上传配置
ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"}
@ -35,24 +87,24 @@ MARKDOWN_DIR.mkdir(exist_ok=True)
CLIENT_DIR.mkdir(exist_ok=True)
EXTERNAL_APPS_DIR.mkdir(exist_ok=True)
USER_DIR.mkdir(exist_ok=True)
VOICEPRINT_DIR.mkdir(exist_ok=True)
AVATAR_DIR.mkdir(exist_ok=True)
LEGACY_VOICEPRINT_DIR.mkdir(exist_ok=True)
LEGACY_AVATAR_DIR.mkdir(exist_ok=True)
# 数据库配置
DATABASE_CONFIG = {
'host': os.getenv('DB_HOST', '127.0.0.1'),
'user': os.getenv('DB_USER', 'root'),
'password': os.getenv('DB_PASSWORD', ''),
'database': os.getenv('DB_NAME', 'imeeting'),
'port': int(os.getenv('DB_PORT', '3306')),
'host': _get_env('DB_HOST', 'MYSQL_HOST', default='127.0.0.1', allow_blank=False),
'user': _get_env('DB_USER', 'MYSQL_USER', default='root', allow_blank=False),
'password': _get_env('DB_PASSWORD', 'MYSQL_PASSWORD', default=''),
'database': _get_env('DB_NAME', 'MYSQL_DATABASE', default='imeeting', allow_blank=False),
'port': _get_int_env('DB_PORT', 'MYSQL_PORT', default=3306),
'charset': 'utf8mb4'
}
# API配置
API_CONFIG = {
'host': os.getenv('API_HOST', '0.0.0.0'),
'port': int(os.getenv('API_PORT', '8000'))
'host': _get_env('API_HOST', default='0.0.0.0', allow_blank=False),
'port': _get_int_env('API_PORT', default=8000)
}
# 七牛云配置
@ -63,25 +115,27 @@ API_CONFIG = {
# 应用配置
APP_CONFIG = {
'base_url': os.getenv('BASE_URL', 'http://imeeting.unisspace.com')
'base_url': _normalize_base_url(_get_env('BASE_URL', default='http://localhost', allow_blank=False))
}
# Redis配置
REDIS_CONFIG = {
'host': os.getenv('REDIS_HOST', '127.0.0.1'),
'port': int(os.getenv('REDIS_PORT', '6379')),
'db': int(os.getenv('REDIS_DB', '0')),
'password': os.getenv('REDIS_PASSWORD', ''),
'host': _get_env('REDIS_HOST', default='127.0.0.1', allow_blank=False),
'port': _get_int_env('REDIS_PORT', default=6379),
'db': _get_int_env('REDIS_DB', default=0),
'password': _get_env('REDIS_PASSWORD', default=''),
'decode_responses': True
}
# Dashscope (Tongyi Qwen) API Key
QWEN_API_KEY = os.getenv('QWEN_API_KEY', 'sk-c2bf06ea56b4491ea3d1e37fdb472b8f')
# 转录轮询配置 - 用于 upload-audio-complete 接口
TRANSCRIPTION_POLL_CONFIG = {
'poll_interval': int(os.getenv('TRANSCRIPTION_POLL_INTERVAL', '10')), # 轮询间隔10秒
'max_wait_time': int(os.getenv('TRANSCRIPTION_MAX_WAIT_TIME', '1800')), # 最大等待30分钟
'poll_interval': _get_int_env('TRANSCRIPTION_POLL_INTERVAL', default=10), # 轮询间隔10秒
'max_wait_time': _get_int_env('TRANSCRIPTION_MAX_WAIT_TIME', default=1800), # 最大等待30分钟
}
# 后台任务配置
BACKGROUND_TASK_CONFIG = {
'summary_workers': _get_int_env('SUMMARY_TASK_MAX_WORKERS', default=2),
'monitor_workers': _get_int_env('MONITOR_TASK_MAX_WORKERS', default=8),
'transcription_status_cache_ttl': _get_int_env('TRANSCRIPTION_STATUS_CACHE_TTL', default=3),
}

View File

@ -10,14 +10,14 @@ def get_db_connection():
connection = None
try:
connection = mysql.connector.connect(**DATABASE_CONFIG)
yield connection
except Error as e:
print(f"数据库连接错误: {e}")
raise HTTPException(status_code=500, detail="数据库连接失败")
try:
yield connection
finally:
if connection and connection.is_connected():
try:
# 确保清理任何未读结果
if connection.unread_result:
connection.consume_results()
connection.close()

View File

@ -5,6 +5,26 @@ from app.services.terminal_service import terminal_service
from app.services.jwt_service import jwt_service
from app.core.response import create_api_response
class MCPPathNormalizeMiddleware:
"""Normalize the public MCP endpoint to /mcp while keeping the mounted app happy."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope.get("path") == "/mcp":
normalized_scope = dict(scope)
normalized_scope["path"] = "/mcp/"
raw_path = scope.get("raw_path")
if raw_path in (None, b"/mcp"):
normalized_scope["raw_path"] = b"/mcp/"
scope = normalized_scope
await self.app(scope, receive, send)
class TerminalCheckMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行

View File

@ -1,91 +1,32 @@
import sys
import os
from pathlib import Path
# 添加项目根目录到 Python 路径
# 无论从哪里运行,都能正确找到 app 模块
current_file = Path(__file__).resolve()
project_root = current_file.parent.parent # backend/
package_dir = current_file.parent
project_root = current_file.parent.parent
# When this file is executed as `python app/main.py`, Python automatically puts
# `/.../backend/app` on sys.path. That shadows the third-party `mcp` package
# with our local `app/mcp` package and breaks `from mcp.server.fastmcp import FastMCP`.
try:
sys.path.remove(str(package_dir))
except ValueError:
pass
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
import uvicorn
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.openapi.docs import get_swagger_ui_html
from app.core.middleware import TerminalCheckMiddleware
from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio, dict_data, hot_words, external_apps, terminals
from app.core.config import UPLOAD_DIR, API_CONFIG
app = FastAPI(
title="iMeeting API",
description="iMeeting API说明",
version="1.1.0",
docs_url=None, # 禁用默认docs使用自定义CDN
redoc_url=None
)
from app.app_factory import create_app
from app.core.config import API_CONFIG
# 添加终端检查中间件 (在CORS之前添加以便位于CORS内部)
app.add_middleware(TerminalCheckMiddleware)
# 添加CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app = create_app()
# 静态文件服务 - 提供音频文件下载
if UPLOAD_DIR.exists():
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
# 包含API路由
app.include_router(auth.router, prefix="/api", tags=["Authentication"])
app.include_router(users.router, prefix="/api", tags=["Users"])
app.include_router(meetings.router, prefix="/api", tags=["Meetings"])
app.include_router(tags.router, prefix="/api", tags=["Tags"])
app.include_router(admin.router, prefix="/api", tags=["Admin"])
app.include_router(admin_dashboard.router, prefix="/api", tags=["AdminDashboard"])
app.include_router(tasks.router, prefix="/api", tags=["Tasks"])
app.include_router(prompts.router, prefix="/api", tags=["Prompts"])
app.include_router(knowledge_base.router, prefix="/api", tags=["KnowledgeBase"])
app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"])
app.include_router(external_apps.router, prefix="/api", tags=["ExternalApps"])
app.include_router(dict_data.router, prefix="/api", tags=["DictData"])
app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"])
app.include_router(audio.router, prefix="/api", tags=["Audio"])
app.include_router(hot_words.router, prefix="/api", tags=["HotWords"])
app.include_router(terminals.router, prefix="/api", tags=["Terminals"])
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
"""自定义Swagger UI使用国内可访问的CDN"""
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js",
swagger_css_url="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css",
)
@app.get("/")
def read_root():
return {"message": "Welcome to iMeeting API"}
@app.get("/health")
def health_check():
"""健康检查端点"""
return {
"status": "healthy",
"service": "iMeeting API",
"version": "1.1.0"
}
if __name__ == "__main__":
# 简单的uvicorn配置避免参数冲突
uvicorn.run(
"app.main:app",
host=API_CONFIG['host'],

View File

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

View File

@ -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

View File

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

View File

@ -1,20 +1,12 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, Union, List
from pydantic import BaseModel, Field, EmailStr
from typing import List, Optional, Any, Dict, Union
import datetime
# 认证相关模型
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
user_id: int
username: str
caption: str
email: EmailStr
avatar_url: Optional[str] = None
token: str
role_id: int
class RoleInfo(BaseModel):
role_id: int
role_name: str
@ -23,102 +15,111 @@ class UserInfo(BaseModel):
user_id: int
username: str
caption: str
email: EmailStr
avatar_url: Optional[str] = None
created_at: datetime.datetime
meetings_created: int
meetings_attended: int
email: Optional[str] = None
role_id: int
role_name: str
avatar_url: Optional[str] = None
created_at: datetime.datetime
class LoginResponse(BaseModel):
token: str
user: UserInfo
class UserListResponse(BaseModel):
users: list[UserInfo]
users: List[UserInfo]
total: int
class CreateUserRequest(BaseModel):
username: str
password: Optional[str] = None
caption: str
email: EmailStr
email: Optional[str] = None
avatar_url: Optional[str] = None
role_id: int
role_id: int = 2
class UpdateUserRequest(BaseModel):
username: Optional[str] = None
caption: Optional[str] = None
email: Optional[str] = None
avatar_url: Optional[str] = None
role_id: Optional[int] = None
avatar_url: Optional[str] = None
class UserLog(BaseModel):
log_id: int
user_id: int
action_type: str
username: str
action: str
details: Optional[str] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
metadata: Optional[dict] = None
created_at: datetime.datetime
# 会议相关模型
class AttendeeInfo(BaseModel):
user_id: int
user_id: Optional[int] = None
username: Optional[str] = None
caption: str
class Tag(BaseModel):
id: int
name: str
color: str
class TranscriptionTaskStatus(BaseModel):
task_id: str
status: str # 'pending', 'processing', 'completed', 'failed'
progress: int # 0-100
meeting_id: int
created_at: Optional[str] = None
updated_at: Optional[str] = None
completed_at: Optional[str] = None
error_message: Optional[str] = None
status: str
progress: int
message: Optional[str] = None
class Meeting(BaseModel):
meeting_id: int
title: str
meeting_time: Optional[datetime.datetime]
summary: Optional[str]
created_at: datetime.datetime
attendees: Union[List[str], List[AttendeeInfo]] # Support both formats
meeting_time: datetime.datetime
description: Optional[str] = None
creator_id: int
creator_username: str
creator_account: Optional[str] = None
created_at: datetime.datetime
attendees: List[AttendeeInfo]
attendee_ids: Optional[List[int]] = None
tags: List[Tag]
audio_file_path: Optional[str] = None
audio_duration: Optional[float] = None
prompt_name: Optional[str] = None
summary: Optional[str] = None
transcription_status: Optional[TranscriptionTaskStatus] = None
tags: Optional[List[Tag]] = []
llm_status: Optional[TranscriptionTaskStatus] = None
prompt_id: Optional[int] = None
prompt_name: Optional[str] = None
overall_status: Optional[str] = None
overall_progress: Optional[int] = None
current_stage: Optional[str] = None
access_password: Optional[str] = None
class TranscriptSegment(BaseModel):
segment_id: int
meeting_id: int
speaker_id: Optional[int] = None # AI解析的原始结果
speaker_id: int
speaker_tag: str
start_time_ms: int
end_time_ms: int
text_content: str
class CreateMeetingRequest(BaseModel):
user_id: int
title: str
meeting_time: Optional[datetime.datetime]
attendee_ids: list[int]
tags: Optional[str] = None
meeting_time: datetime.datetime
attendee_ids: List[int] = Field(default_factory=list)
description: Optional[str] = None
tags: Optional[str] = None # 逗号分隔
prompt_id: Optional[int] = None
class UpdateMeetingRequest(BaseModel):
title: str
meeting_time: Optional[datetime.datetime]
summary: Optional[str]
attendee_ids: list[int]
title: Optional[str] = None
meeting_time: Optional[datetime.datetime] = None
attendee_ids: Optional[List[int]] = None
description: Optional[str] = None
tags: Optional[str] = None
summary: Optional[str] = None
prompt_id: Optional[int] = None
class SpeakerTagUpdateRequest(BaseModel):
speaker_id: int # 使用原始speaker_id整数
speaker_id: int
new_tag: str
class BatchSpeakerTagUpdateRequest(BaseModel):
@ -135,45 +136,66 @@ class PasswordChangeRequest(BaseModel):
old_password: str
new_password: str
# 提示词模版模型
class PromptBase(BaseModel):
name: str
task_type: str # MEETING_TASK, KNOWLEDGE_TASK
content: str
desc: Optional[str] = None
is_system: bool = False
is_default: bool = False
is_active: bool = True
class PromptCreate(PromptBase):
pass
class PromptUpdate(BaseModel):
name: Optional[str] = None
task_type: Optional[str] = None
content: Optional[str] = None
desc: Optional[str] = None
is_system: Optional[bool] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
class PromptInfo(PromptBase):
id: int
creator_id: Optional[int] = None
created_at: datetime.datetime
# 知识库相关模型
class KnowledgeBase(BaseModel):
kb_id: int
title: str
content: Optional[str] = None
content: str
creator_id: int
creator_caption: str # To show in the UI
created_by_name: str
is_shared: bool
source_meeting_ids: Optional[str] = None
user_prompt: Optional[str] = None
tags: Union[Optional[str], Optional[List[Tag]]] = None # 支持字符串或Tag列表
created_at: datetime.datetime
updated_at: datetime.datetime
source_meeting_count: Optional[int] = 0
created_by_name: Optional[str] = None
source_meeting_count: int
source_meetings: Optional[List[Meeting]] = None
user_prompt: Optional[str] = None
tags: Optional[List[str]] = None
prompt_id: Optional[int] = None
class KnowledgeBaseTask(BaseModel):
task_id: str
user_id: int
kb_id: int
user_prompt: Optional[str] = None
status: str
progress: int
error_message: Optional[str] = None
created_at: datetime.datetime
updated_at: datetime.datetime
completed_at: Optional[datetime.datetime] = None
message: Optional[str] = None
result: Optional[str] = None
class CreateKnowledgeBaseRequest(BaseModel):
title: Optional[str] = None # 改为可选,后台自动生成
is_shared: bool
user_prompt: Optional[str] = None
source_meeting_ids: Optional[str] = None
tags: Optional[str] = None
prompt_id: Optional[int] = None # 提示词模版ID如果不指定则使用默认模版
source_meeting_ids: str # 逗号分隔
is_shared: bool = False
prompt_id: Optional[int] = None
class UpdateKnowledgeBaseRequest(BaseModel):
title: str
title: Optional[str] = None
content: Optional[str] = None
tags: Optional[str] = None
is_shared: Optional[bool] = None
class KnowledgeBaseListResponse(BaseModel):
kbs: List[KnowledgeBase]
@ -182,70 +204,62 @@ class KnowledgeBaseListResponse(BaseModel):
# 客户端下载相关模型
class ClientDownload(BaseModel):
id: int
platform_type: Optional[str] = None # 兼容旧版:'mobile', 'desktop', 'terminal'
platform_name: Optional[str] = None # 兼容旧版:'ios', 'android', 'windows', 'mac_intel', 'mac_m', 'linux'
platform_code: str # 新版平台编码,关联 dict_data.dict_code
platform_code: str
platform_type: str # mobile, desktop, terminal
platform_name: str
version: str
version_code: int
download_url: str
file_size: Optional[int] = None
release_notes: Optional[str] = None
min_system_version: Optional[str] = None
is_active: bool
is_latest: bool
min_system_version: Optional[str] = None
created_at: datetime.datetime
updated_at: datetime.datetime
created_by: Optional[int] = None
class CreateClientDownloadRequest(BaseModel):
platform_type: Optional[str] = None # 兼容旧版
platform_name: Optional[str] = None # 兼容旧版
platform_code: str # 必填,关联 dict_data
platform_code: str
platform_type: Optional[str] = None
platform_name: Optional[str] = None
version: str
version_code: int
download_url: str
file_size: Optional[int] = None
release_notes: Optional[str] = None
min_system_version: Optional[str] = None
is_active: bool = True
is_latest: bool = False
min_system_version: Optional[str] = None
class UpdateClientDownloadRequest(BaseModel):
platform_code: Optional[str] = None
platform_type: Optional[str] = None
platform_name: Optional[str] = None
platform_code: Optional[str] = None
version: Optional[str] = None
version_code: Optional[int] = None
download_url: Optional[str] = None
file_size: Optional[int] = None
release_notes: Optional[str] = None
min_system_version: Optional[str] = None
is_active: Optional[bool] = None
is_latest: Optional[bool] = None
min_system_version: Optional[str] = None
class ClientDownloadListResponse(BaseModel):
clients: List[ClientDownload]
total: int
# 声纹采集相关模型
# 声纹相关模型
class VoiceprintInfo(BaseModel):
vp_id: int
user_id: int
file_path: str
file_size: Optional[int] = None
duration_seconds: Optional[float] = None
collected_at: datetime.datetime
updated_at: datetime.datetime
voiceprint_data: Any
created_at: datetime.datetime
class VoiceprintStatus(BaseModel):
has_voiceprint: bool
vp_id: Optional[int] = None
file_path: Optional[str] = None
duration_seconds: Optional[float] = None
collected_at: Optional[datetime.datetime] = None
updated_at: Optional[datetime.datetime] = None
class VoiceprintTemplate(BaseModel):
template_text: str
content: str
duration_seconds: int
sample_rate: int
channels: int
@ -277,13 +291,51 @@ class RolePermissionInfo(BaseModel):
class UpdateRolePermissionsRequest(BaseModel):
menu_ids: List[int]
class CreateRoleRequest(BaseModel):
role_name: str
class UpdateRoleRequest(BaseModel):
role_name: str
class CreateMenuRequest(BaseModel):
menu_code: str
menu_name: str
menu_icon: Optional[str] = None
menu_url: Optional[str] = None
menu_type: str = "link"
parent_id: Optional[int] = None
sort_order: int = 0
is_active: bool = True
description: Optional[str] = None
class UpdateMenuRequest(BaseModel):
menu_code: Optional[str] = None
menu_name: Optional[str] = None
menu_icon: Optional[str] = None
menu_url: Optional[str] = None
menu_type: Optional[str] = None
parent_id: Optional[int] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
description: Optional[str] = None
class UserMcpInfo(BaseModel):
id: int
user_id: int
bot_id: str
bot_secret: str
status: int
last_used_at: Optional[datetime.datetime] = None
created_at: datetime.datetime
updated_at: datetime.datetime
# 专用终端设备模型
class Terminal(BaseModel):
id: int
imei: str
terminal_name: Optional[str] = None
terminal_type: str
terminal_type_name: Optional[str] = None # 终端类型名称(从字典获取)
terminal_type_name: Optional[str] = None
description: Optional[str] = None
status: int # 1: 启用, 0: 停用
is_activated: int # 1: 已激活, 0: 未激活
@ -296,18 +348,23 @@ class Terminal(BaseModel):
updated_at: datetime.datetime
created_by: Optional[int] = None
creator_username: Optional[str] = None
current_user_id: Optional[int] = None
current_username: Optional[str] = None
current_user_caption: Optional[str] = None
class CreateTerminalRequest(BaseModel):
imei: str
terminal_name: Optional[str] = None
terminal_type: str
description: Optional[str] = None
firmware_version: Optional[str] = None
mac_address: Optional[str] = None
status: int = 1
class UpdateTerminalRequest(BaseModel):
terminal_name: Optional[str] = None
terminal_type: Optional[str] = None
description: Optional[str] = None
status: Optional[int] = None
firmware_version: Optional[str] = None
mac_address: Optional[str] = None
status: Optional[int] = None

View File

@ -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)}")

View File

@ -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)}")

View File

@ -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)}")

View File

@ -8,7 +8,7 @@ from typing import Optional, Dict, Any, List
import redis
from app.core.database import get_db_connection
from app.services.llm_service import LLMService
from app.services.llm_service import LLMService, LLMServiceError
class AsyncKnowledgeBaseService:
"""异步知识库服务类 - 处理知识库相关的异步任务"""
@ -45,7 +45,7 @@ class AsyncKnowledgeBaseService:
"""
cursor.execute(query, (task_id, user_id, kb_id, user_prompt, prompt_id))
else:
# Fallback to the old method if no cursor is provided
# 无外部事务时,使用服务内的持久化入口。
self._save_task_to_db(task_id, user_id, kb_id, user_prompt, prompt_id)
current_time = datetime.now().isoformat()
@ -96,9 +96,7 @@ class AsyncKnowledgeBaseService:
# 4. 调用LLM API
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在生成知识库...")
generated_content = self.llm_service._call_llm_api(full_prompt)
if not generated_content:
raise Exception("LLM API调用失败或返回空内容")
generated_content = self.llm_service.call_llm_api_or_raise(full_prompt)
# 5. 保存结果到数据库
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存结果...")
@ -110,6 +108,11 @@ class AsyncKnowledgeBaseService:
print(f"Task {task_id} completed successfully")
except LLMServiceError as e:
error_msg = e.message or str(e)
print(f"Task {task_id} failed with LLM error: {error_msg}")
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
except Exception as e:
error_msg = str(e)
print(f"Task {task_id} failed: {error_msg}")

View File

@ -1,17 +1,30 @@
"""
异步会议服务 - 处理会议总结生成的异步任务
采用FastAPI BackgroundTasks模式
采用受控线程池执行避免阻塞 Web 请求进程
"""
import uuid
import time
import re
from datetime import datetime
from typing import Optional, Dict, Any, List
from pathlib import Path
import redis
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG
from app.core.config import REDIS_CONFIG, TRANSCRIPTION_POLL_CONFIG, BACKGROUND_TASK_CONFIG, AUDIO_DIR, BASE_DIR
from app.core.database import get_db_connection
from app.services.async_transcription_service import AsyncTranscriptionService
from app.services.llm_service import LLMService
from app.services.background_task_runner import KeyedBackgroundTaskRunner
from app.services.llm_service import LLMService, LLMServiceError
summary_task_runner = KeyedBackgroundTaskRunner(
max_workers=BACKGROUND_TASK_CONFIG['summary_workers'],
thread_name_prefix="imeeting-summary",
)
monitor_task_runner = KeyedBackgroundTaskRunner(
max_workers=BACKGROUND_TASK_CONFIG['monitor_workers'],
thread_name_prefix="imeeting-monitor",
)
class AsyncMeetingService:
"""异步会议服务类 - 处理会议相关的异步任务"""
@ -23,14 +36,48 @@ class AsyncMeetingService:
self.redis_client = redis.Redis(**REDIS_CONFIG)
self.llm_service = LLMService() # 复用现有的同步LLM服务
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None) -> str:
def enqueue_summary_generation(
self,
meeting_id: int,
user_prompt: str = "",
prompt_id: Optional[int] = None,
model_code: Optional[str] = None
) -> tuple[str, bool]:
"""创建并提交总结任务;若已有运行中的同会议总结任务,则直接返回现有任务。"""
existing_task = self._get_existing_summary_task(meeting_id)
if existing_task:
return existing_task, False
task_id = self.start_summary_generation(meeting_id, user_prompt, prompt_id, model_code)
summary_task_runner.submit(f"meeting-summary:{task_id}", self._process_task, task_id)
return task_id, True
def enqueue_transcription_monitor(
self,
meeting_id: int,
transcription_task_id: str,
prompt_id: Optional[int] = None,
model_code: Optional[str] = None
) -> bool:
"""提交转录监控任务,避免同一转录任务重复轮询。"""
return monitor_task_runner.submit(
f"transcription-monitor:{transcription_task_id}",
self.monitor_and_auto_summarize,
meeting_id,
transcription_task_id,
prompt_id,
model_code,
)
def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None, model_code: Optional[str] = None) -> str:
"""
创建异步总结任务任务的执行将由外部如API层的BackgroundTasks触发
创建异步总结任务任务的执行将由后台线程池触发
Args:
meeting_id: 会议ID
user_prompt: 用户额外提示词
prompt_id: 可选的提示词模版ID如果不指定则使用默认模版
model_code: 可选的LLM模型编码如果不指定则使用默认模型
Returns:
str: 任务ID
@ -40,7 +87,7 @@ class AsyncMeetingService:
task_id = str(uuid.uuid4())
# 在数据库中创建任务记录
self._save_task_to_db(task_id, meeting_id, user_prompt, prompt_id)
self._save_task_to_db(task_id, meeting_id, user_prompt, prompt_id, model_code)
# 将任务详情存入Redis用于快速查询状态
current_time = datetime.now().isoformat()
@ -49,6 +96,7 @@ class AsyncMeetingService:
'meeting_id': str(meeting_id),
'user_prompt': user_prompt,
'prompt_id': str(prompt_id) if prompt_id else '',
'model_code': model_code or '',
'status': 'pending',
'progress': '0',
'created_at': current_time,
@ -61,13 +109,15 @@ class AsyncMeetingService:
except Exception as e:
print(f"Error starting summary generation: {e}")
raise e
raise
def _process_task(self, task_id: str):
"""
处理单个异步任务的函数设计为由BackgroundTasks调用
处理单个异步任务的函数在线程池中执行
"""
print(f"Background task started for meeting summary task: {task_id}")
lock_token = None
lock_key = f"lock:meeting-summary-task:{task_id}"
try:
# 从Redis获取任务数据
task_data = self.redis_client.hgetall(f"llm_task:{task_id}")
@ -79,6 +129,11 @@ class AsyncMeetingService:
user_prompt = task_data.get('user_prompt', '')
prompt_id_str = task_data.get('prompt_id', '')
prompt_id = int(prompt_id_str) if prompt_id_str and prompt_id_str != '' else None
model_code = task_data.get('model_code', '') or None
lock_token = self._acquire_lock(lock_key, ttl_seconds=7200)
if not lock_token:
print(f"Task {task_id} is already being processed, skipping duplicate execution")
return
# 1. 更新状态为processing
self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...")
@ -89,33 +144,47 @@ class AsyncMeetingService:
if not transcript_text:
raise Exception("无法获取会议转录内容")
# 3. 构建提示词
# 3. 构建消息
self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...")
full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id)
messages = self._build_messages(meeting_id, transcript_text, user_prompt, prompt_id)
# 4. 调用LLM API
# 4. 调用LLM API(支持指定模型)
self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...")
summary_content = self.llm_service._call_llm_api(full_prompt)
if not summary_content:
raise Exception("LLM API调用失败或返回空内容")
summary_content = self.llm_service.call_llm_api_messages_or_raise(messages, model_code=model_code)
# 5. 保存结果到主表
self._update_task_status_in_redis(task_id, 'processing', 95, message="保存总结结果...")
self._update_task_status_in_redis(task_id, 'processing', 90, message="保存总结结果...")
self._save_summary_to_db(meeting_id, summary_content, user_prompt, prompt_id)
# 6. 任务完成
self._update_task_in_db(task_id, 'completed', 100, result=summary_content)
self._update_task_status_in_redis(task_id, 'completed', 100, result=summary_content)
# 6. 导出MD文件到音频同目录
self._update_task_status_in_redis(task_id, 'processing', 95, message="导出Markdown文件...")
md_path = self._export_summary_md(meeting_id, summary_content, task_id=task_id)
if not md_path:
raise RuntimeError("导出Markdown文件失败未生成文件路径")
# 7. 任务完成result保存MD文件路径
self._update_task_in_db(task_id, 'completed', 100, result=md_path)
self._update_task_status_in_redis(task_id, 'completed', 100, message="任务已完成", result=md_path)
print(f"Task {task_id} completed successfully")
except LLMServiceError as e:
error_msg = e.message or str(e)
print(f"Task {task_id} failed with LLM error: {error_msg}")
self._mark_task_failed(task_id, error_msg)
except Exception as e:
error_msg = str(e)
print(f"Task {task_id} failed: {error_msg}")
# 更新失败状态
self._update_task_in_db(task_id, 'failed', 0, error_message=error_msg)
self._update_task_status_in_redis(task_id, 'failed', 0, error_message=error_msg)
self._mark_task_failed(task_id, error_msg)
finally:
self._release_lock(lock_key, lock_token)
def monitor_and_auto_summarize(self, meeting_id: int, transcription_task_id: str, prompt_id: Optional[int] = None):
def monitor_and_auto_summarize(
self,
meeting_id: int,
transcription_task_id: str,
prompt_id: Optional[int] = None,
model_code: Optional[str] = None
):
"""
监控转录任务完成后自动生成总结
此方法设计为由BackgroundTasks调用在后台运行
@ -124,18 +193,24 @@ class AsyncMeetingService:
meeting_id: 会议ID
transcription_task_id: 转录任务ID
prompt_id: 提示词模版ID可选如果不指定则使用默认模版
model_code: 总结模型编码可选如果不指定则使用默认模型
流程:
1. 循环轮询转录任务状态
2. 转录成功后自动启动总结任务
3. 转录失败或超时则停止轮询并记录日志
"""
print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}")
print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
# 获取配置参数
poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval']
max_wait_time = TRANSCRIPTION_POLL_CONFIG['max_wait_time']
max_polls = max_wait_time // poll_interval
lock_key = f"lock:transcription-monitor:{transcription_task_id}"
lock_token = self._acquire_lock(lock_key, ttl_seconds=max_wait_time + poll_interval)
if not lock_token:
print(f"[Monitor] Monitor task already running for transcription task {transcription_task_id}, skipping duplicate worker")
return
# 延迟导入以避免循环导入
transcription_service = AsyncTranscriptionService()
@ -166,11 +241,16 @@ class AsyncMeetingService:
else:
# 启动总结任务
try:
summary_task_id = self.start_summary_generation(meeting_id, user_prompt="", prompt_id=prompt_id)
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
# 在后台执行总结任务
self._process_task(summary_task_id)
summary_task_id, created = self.enqueue_summary_generation(
meeting_id,
user_prompt="",
prompt_id=prompt_id,
model_code=model_code
)
if created:
print(f"[Monitor] Summary task {summary_task_id} started for meeting {meeting_id}")
else:
print(f"[Monitor] Reused existing summary task {summary_task_id} for meeting {meeting_id}")
except Exception as e:
error_msg = f"Failed to start summary generation: {e}"
@ -207,9 +287,44 @@ class AsyncMeetingService:
except Exception as e:
print(f"[Monitor] Fatal error in monitor_and_auto_summarize: {e}")
finally:
self._release_lock(lock_key, lock_token)
# --- 会议相关方法 ---
def _export_summary_md(self, meeting_id: int, summary_content: str, task_id: Optional[str] = None) -> str:
"""将总结内容导出为MD文件保存到音频同目录返回 /uploads/... 相对路径"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT title FROM meetings WHERE meeting_id = %s", (meeting_id,))
meeting = cursor.fetchone()
cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,))
audio = cursor.fetchone()
title = meeting['title'] if meeting else f"meeting_{meeting_id}"
if audio and audio.get('file_path'):
audio_path = BASE_DIR / str(audio['file_path']).lstrip('/')
md_dir = audio_path.parent
else:
md_dir = AUDIO_DIR / str(meeting_id)
md_dir.mkdir(parents=True, exist_ok=True)
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_', '.')).strip()
if not safe_title:
safe_title = f"meeting_{meeting_id}"
timestamp_suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
task_suffix = ""
if task_id:
task_suffix = f"_{str(task_id).replace('-', '')[:8]}"
md_path = md_dir / f"{safe_title}_总结_{timestamp_suffix}{task_suffix}.md"
md_path.write_text(summary_content, encoding='utf-8')
relative_md_path = "/" + str(md_path.relative_to(BASE_DIR)).replace("\\", "/")
print(f"总结MD文件已保存: {relative_md_path}")
return relative_md_path
except Exception as e:
raise RuntimeError(f"导出总结MD文件失败: {e}") from e
def _get_meeting_transcript(self, meeting_id: int) -> str:
"""从数据库获取会议转录内容"""
try:
@ -241,49 +356,176 @@ class AsyncMeetingService:
print(f"获取会议转录内容错误: {e}")
return ""
def _build_prompt(self, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str:
def _get_meeting_prompt_context(self, meeting_id: int) -> Dict[str, Any]:
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
cursor.execute(
"""
SELECT m.title, m.meeting_time, u.caption AS creator_name
FROM meetings m
LEFT JOIN sys_users u ON m.user_id = u.user_id
WHERE m.meeting_id = %s
""",
(meeting_id,)
)
meeting = cursor.fetchone()
if not meeting:
cursor.close()
return {}
meeting_time = meeting.get('meeting_time')
meeting_time_text = ''
if isinstance(meeting_time, datetime):
meeting_time_text = meeting_time.strftime('%Y-%m-%d %H:%M:%S')
elif meeting_time:
meeting_time_text = str(meeting_time)
cursor.execute(
"""
SELECT u.caption
FROM attendees a
JOIN sys_users u ON a.user_id = u.user_id
WHERE a.meeting_id = %s
ORDER BY u.caption ASC
""",
(meeting_id,)
)
attendee_rows = cursor.fetchall()
attendee_names = [row.get('caption') for row in attendee_rows if row.get('caption')]
attendee_text = ''.join(attendee_names)
cursor.close()
return {
'title': meeting.get('title') or '',
'meeting_time': meeting_time_text,
'meeting_time_value': meeting_time,
'creator_name': meeting.get('creator_name') or '',
'attendees': attendee_text
}
except Exception as e:
print(f"获取会议提示词上下文错误: {e}")
return {}
def _format_meeting_time_value(self, meeting_time_value: Any, custom_format: Optional[str] = None) -> str:
default_format = '%Y-%m-%d %H:%M:%S'
if isinstance(meeting_time_value, datetime):
try:
return meeting_time_value.strftime(custom_format or default_format)
except Exception:
return meeting_time_value.strftime(default_format)
if meeting_time_value:
meeting_time_text = str(meeting_time_value)
if custom_format:
try:
parsed_time = datetime.fromisoformat(meeting_time_text.replace('Z', '+00:00'))
return parsed_time.strftime(custom_format)
except Exception:
return meeting_time_text
return meeting_time_text
return ''
def _apply_prompt_variables(self, template: str, variables: Dict[str, Any]) -> str:
if not template:
return template
rendered = re.sub(
r"\{\{\s*meeting_time\s*:\s*([^{}]+?)\s*\}\}",
lambda match: self._format_meeting_time_value(
variables.get('meeting_time_value'),
match.group(1).strip()
),
template
)
for key, value in variables.items():
if key == 'meeting_time_value':
continue
rendered = rendered.replace(f"{{{{{key}}}}}", value or '')
rendered = rendered.replace(f"{{{{ {key} }}}}", value or '')
return rendered
def _build_messages(self, meeting_id: int, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> List[Dict[str, str]]:
"""
构建完整的提示词
使用数据库中配置的MEETING_TASK提示词模板
构建会议总结消息数组
使用数据库中配置的 MEETING_TASK 提示词模板作为任务级 system 指令
Args:
meeting_id: 会议ID
transcript_text: 会议转录文本
user_prompt: 用户额外提示词
prompt_id: 可选的提示词模版ID如果不指定则使用默认模版
"""
# 从数据库获取会议任务的提示词模板支持指定prompt_id
system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
task_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id)
meeting_context = self._get_meeting_prompt_context(meeting_id)
prompt_variables = {
'meeting_id': str(meeting_id),
'meeting_title': meeting_context.get('title', ''),
'meeting_time': meeting_context.get('meeting_time', ''),
'meeting_creator': meeting_context.get('creator_name', ''),
'meeting_attendees': meeting_context.get('attendees', ''),
'meeting_time_value': meeting_context.get('meeting_time_value')
}
rendered_task_prompt = self._apply_prompt_variables(task_prompt, prompt_variables)
rendered_user_prompt = self._apply_prompt_variables(user_prompt, prompt_variables) if user_prompt else ''
prompt = f"{system_prompt}\n\n"
meeting_info_lines = [
f"会议ID{prompt_variables['meeting_id']}",
f"会议标题:{prompt_variables['meeting_title'] or '未提供'}",
f"会议时间:{prompt_variables['meeting_time'] or '未提供'}",
f"会议创建人:{prompt_variables['meeting_creator'] or '未提供'}",
f"参会人员:{prompt_variables['meeting_attendees'] or '未提供'}",
]
meeting_info_message = "\n".join(meeting_info_lines)
user_requirement_message = rendered_user_prompt or "无额外要求"
if user_prompt:
prompt += f"用户额外要求:{user_prompt}\n\n"
messages: List[Dict[str, str]] = []
if rendered_task_prompt:
messages.append({"role": "system", "content": rendered_task_prompt})
messages.append({
"role": "user",
"content": (
"以下是本次会议的上下文信息,请结合这些信息理解会议背景。\n\n"
f"{meeting_info_message}\n\n"
"以下是用户额外要求,如与事实冲突请以转录原文为准:\n"
f"{user_requirement_message}"
)
})
messages.append({
"role": "user",
"content": (
"以下是会议转录原文,请严格依据原文生成会议总结。\n"
"如果信息不足,请明确写出“原文未明确”或“需人工确认”。\n\n"
"<meeting_transcript>\n"
f"{transcript_text}\n"
"</meeting_transcript>"
)
})
prompt += f"会议转录内容:\n{transcript_text}\n\n请根据以上内容生成会议总结:"
return messages
return prompt
def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str, prompt_id: Optional[int] = None) -> Optional[int]:
def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str, prompt_id: Optional[int] = None) -> int:
"""保存总结到数据库 - 更新meetings表的summary、user_prompt、prompt_id和updated_at字段"""
try:
with get_db_connection() as connection:
cursor = connection.cursor()
with get_db_connection() as connection:
cursor = connection.cursor()
cursor.execute("SELECT 1 FROM meetings WHERE meeting_id = %s LIMIT 1", (meeting_id,))
if not cursor.fetchone():
raise RuntimeError(f"会议不存在,无法保存总结: meeting_id={meeting_id}")
# 更新meetings表的summary、user_prompt、prompt_id和updated_at字段
update_query = """
UPDATE meetings
SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW()
WHERE meeting_id = %s
"""
cursor.execute(update_query, (summary_content, user_prompt, prompt_id, meeting_id))
connection.commit()
update_query = """
UPDATE meetings
SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW()
WHERE meeting_id = %s
"""
cursor.execute(update_query, (summary_content, user_prompt, prompt_id, meeting_id))
connection.commit()
print(f"成功保存会议总结到meetings表meeting_id: {meeting_id}, prompt_id: {prompt_id}")
return meeting_id
except Exception as e:
print(f"保存总结到数据库错误: {e}")
return None
print(f"成功保存会议总结到meetings表meeting_id: {meeting_id}, prompt_id: {prompt_id}")
return meeting_id
# --- 状态查询和数据库操作方法 ---
@ -295,27 +537,63 @@ class AsyncMeetingService:
task_data = self._get_task_from_db(task_id)
if not task_data:
return {'task_id': task_id, 'status': 'not_found', 'error_message': 'Task not found'}
self._resume_task_if_needed(task_id, task_data)
task_data = self.redis_client.hgetall(f"llm_task:{task_id}") or task_data
return {
'task_id': task_id,
'status': task_data.get('status', 'unknown'),
'progress': int(task_data.get('progress', 0)),
'meeting_id': int(task_data.get('meeting_id', 0)),
'model_code': self._normalize_optional_text(task_data.get('model_code')),
'created_at': task_data.get('created_at'),
'updated_at': task_data.get('updated_at'),
'result': task_data.get('result'),
'error_message': task_data.get('error_message')
'message': task_data.get('message'),
'result': self._normalize_optional_text(task_data.get('result')),
'error_message': self._normalize_optional_text(task_data.get('error_message'))
}
except Exception as e:
print(f"Error getting task status: {e}")
return {'task_id': task_id, 'status': 'error', 'error_message': str(e)}
def _resume_task_if_needed(self, task_id: str, task_data: Dict[str, Any]) -> None:
"""恢复服务重启后丢失在内存中的总结任务。"""
try:
status = str(task_data.get('status') or '').lower()
if status not in {'pending', 'processing'}:
return
restored_data = {
'task_id': task_id,
'meeting_id': str(task_data.get('meeting_id') or ''),
'user_prompt': '' if task_data.get('user_prompt') in (None, 'None') else str(task_data.get('user_prompt')),
'prompt_id': '' if task_data.get('prompt_id') in (None, 'None') else str(task_data.get('prompt_id')),
'model_code': '' if task_data.get('model_code') in (None, 'None') else str(task_data.get('model_code')),
'status': status,
'progress': str(task_data.get('progress') or 0),
'created_at': task_data.get('created_at') or datetime.now().isoformat(),
'updated_at': datetime.now().isoformat(),
'message': '任务恢复中,准备继续执行...',
}
self.redis_client.hset(f"llm_task:{task_id}", mapping=restored_data)
self.redis_client.expire(f"llm_task:{task_id}", 86400)
submitted = summary_task_runner.submit(f"meeting-summary:{task_id}", self._process_task, task_id)
print(f"[LLM Task Recovery] task_id={task_id}, status={status}, submitted={submitted}")
except Exception as e:
print(f"Error resuming summary task {task_id}: {e}")
def get_meeting_llm_tasks(self, meeting_id: int) -> List[Dict[str, Any]]:
"""获取会议的所有LLM任务"""
try:
with get_db_connection() as connection:
cursor = connection.cursor(dictionary=True)
query = "SELECT task_id, status, progress, user_prompt, created_at, completed_at, error_message FROM llm_tasks WHERE meeting_id = %s ORDER BY created_at DESC"
query = """
SELECT task_id, status, progress, user_prompt, model_code, result, created_at, completed_at, error_message
FROM llm_tasks
WHERE meeting_id = %s
ORDER BY created_at DESC
"""
cursor.execute(query, (meeting_id,))
tasks = cursor.fetchall()
for task in tasks:
@ -342,7 +620,7 @@ class AsyncMeetingService:
# 查询最新的LLM任务
query = """
SELECT task_id, status, progress, created_at, completed_at, error_message
SELECT task_id, status, progress, model_code, result, created_at, completed_at, error_message
FROM llm_tasks
WHERE meeting_id = %s
ORDER BY created_at DESC
@ -368,8 +646,10 @@ class AsyncMeetingService:
'status': task_record['status'],
'progress': task_record['progress'] or 0,
'meeting_id': meeting_id,
'model_code': task_record.get('model_code'),
'created_at': task_record['created_at'].isoformat() if task_record['created_at'] else None,
'completed_at': task_record['completed_at'].isoformat() if task_record['completed_at'] else None,
'result': task_record.get('result'),
'error_message': task_record['error_message']
}
@ -380,19 +660,31 @@ class AsyncMeetingService:
def _update_task_status_in_redis(self, task_id: str, status: str, progress: int, message: str = None, result: str = None, error_message: str = None):
"""更新Redis中的任务状态"""
try:
redis_key = f"llm_task:{task_id}"
update_data = {
'status': status,
'progress': str(progress),
'updated_at': datetime.now().isoformat()
}
if message: update_data['message'] = message
if result: update_data['result'] = result
if error_message: update_data['error_message'] = error_message
self.redis_client.hset(f"llm_task:{task_id}", mapping=update_data)
if result is not None: update_data['result'] = result
if error_message is not None: update_data['error_message'] = error_message
self.redis_client.hset(redis_key, mapping=update_data)
if status == 'failed':
self.redis_client.hdel(redis_key, 'result')
elif status == 'completed':
self.redis_client.hdel(redis_key, 'error_message')
except Exception as e:
print(f"Error updating task status in Redis: {e}")
def _save_task_to_db(self, task_id: str, meeting_id: int, user_prompt: str, prompt_id: Optional[int] = None):
def _save_task_to_db(
self,
task_id: str,
meeting_id: int,
user_prompt: str,
prompt_id: Optional[int] = None,
model_code: Optional[str] = None
):
"""保存任务到数据库
Args:
@ -400,12 +692,16 @@ class AsyncMeetingService:
meeting_id: 会议ID
user_prompt: 用户额外提示词
prompt_id: 可选的提示词模版ID如果为None则使用默认模版
model_code: 可选的模型编码用于恢复/重试时复用原始模型
"""
try:
with get_db_connection() as connection:
cursor = connection.cursor()
insert_query = "INSERT INTO llm_tasks (task_id, meeting_id, user_prompt, prompt_id, status, progress, created_at) VALUES (%s, %s, %s, %s, 'pending', 0, NOW())"
cursor.execute(insert_query, (task_id, meeting_id, user_prompt, prompt_id))
insert_query = """
INSERT INTO llm_tasks (task_id, meeting_id, user_prompt, prompt_id, model_code, status, progress, created_at)
VALUES (%s, %s, %s, %s, %s, 'pending', 0, NOW())
"""
cursor.execute(insert_query, (task_id, meeting_id, user_prompt, prompt_id, model_code))
connection.commit()
print(f"[Meeting Service] Task saved successfully to database")
except Exception as e:
@ -414,20 +710,42 @@ class AsyncMeetingService:
def _update_task_in_db(self, task_id: str, status: str, progress: int, result: str = None, error_message: str = None):
"""更新数据库中的任务状态"""
try:
with get_db_connection() as connection:
cursor = connection.cursor()
params = [status, progress, error_message, task_id]
if status == 'completed':
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s, result = %s, completed_at = NOW() WHERE task_id = %s"
params.insert(2, result)
else:
query = "UPDATE llm_tasks SET status = %s, progress = %s, error_message = %s WHERE task_id = %s"
with get_db_connection() as connection:
cursor = connection.cursor()
if status == 'completed':
query = """
UPDATE llm_tasks
SET status = %s, progress = %s, result = %s, error_message = NULL, completed_at = NOW()
WHERE task_id = %s
"""
params = (status, progress, result, task_id)
else:
query = """
UPDATE llm_tasks
SET status = %s, progress = %s, result = NULL, error_message = %s, completed_at = NULL
WHERE task_id = %s
"""
params = (status, progress, error_message, task_id)
cursor.execute(query, tuple(params))
connection.commit()
except Exception as e:
print(f"Error updating task in database: {e}")
cursor.execute(query, params)
if cursor.rowcount == 0:
raise RuntimeError(f"更新LLM任务状态失败任务不存在: task_id={task_id}")
connection.commit()
def _mark_task_failed(self, task_id: str, error_message: str) -> None:
"""尽力持久化失败状态,避免原始异常被状态更新失败覆盖。"""
try:
self._update_task_in_db(task_id, 'failed', 0, error_message=error_message)
except Exception as update_error:
print(f"Error updating failed task in database: {update_error}")
self._update_task_status_in_redis(
task_id,
'failed',
0,
message="任务执行失败",
error_message=error_message,
)
def _get_task_from_db(self, task_id: str) -> Optional[Dict[str, str]]:
"""从数据库获取任务信息"""
@ -438,13 +756,21 @@ class AsyncMeetingService:
cursor.execute(query, (task_id,))
task = cursor.fetchone()
if task:
# 确保所有字段都是字符串以匹配Redis的行为
return {k: v.isoformat() if isinstance(v, datetime) else str(v) for k, v in task.items()}
return {
key: value.isoformat() if isinstance(value, datetime) else (None if value is None else str(value))
for key, value in task.items()
}
return None
except Exception as e:
print(f"Error getting task from database: {e}")
return None
@staticmethod
def _normalize_optional_text(value: Any) -> Optional[str]:
if value in (None, "", "None"):
return None
return str(value)
def _get_existing_summary_task(self, meeting_id: int) -> Optional[str]:
"""
检查会议是否已经有总结任务用于并发控制
@ -471,5 +797,30 @@ class AsyncMeetingService:
print(f"Error checking existing summary task: {e}")
return None
def _acquire_lock(self, lock_key: str, ttl_seconds: int) -> Optional[str]:
"""使用 Redis 分布式锁防止多 worker 重复执行同一后台任务。"""
try:
token = str(uuid.uuid4())
acquired = self.redis_client.set(lock_key, token, nx=True, ex=max(30, ttl_seconds))
if acquired:
return token
except Exception as e:
print(f"Error acquiring lock {lock_key}: {e}")
return None
def _release_lock(self, lock_key: str, token: Optional[str]) -> None:
if not token:
return
try:
release_script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
"""
self.redis_client.eval(release_script, 1, lock_key, token)
except Exception as e:
print(f"Error releasing lock {lock_key}: {e}")
# 创建全局实例
async_meeting_service = AsyncMeetingService()

View File

@ -1,5 +1,5 @@
import uuid
import json
import os
import redis
import requests
from datetime import datetime
@ -9,20 +9,153 @@ from http import HTTPStatus
import dashscope
from dashscope.audio.asr import Transcription
from app.core.config import QWEN_API_KEY, REDIS_CONFIG, APP_CONFIG
from app.core.config import REDIS_CONFIG, APP_CONFIG, BACKGROUND_TASK_CONFIG
from app.core.database import get_db_connection
from app.services.system_config_service import SystemConfigService
class _DefaultTimeoutSession(requests.Session):
"""为 requests.Session 注入默认超时。"""
def __init__(self, default_timeout: Optional[int] = None):
super().__init__()
self.default_timeout = default_timeout
def request(self, method, url, **kwargs):
if "timeout" not in kwargs and self.default_timeout:
kwargs["timeout"] = self.default_timeout
return super().request(method, url, **kwargs)
class AsyncTranscriptionService:
"""异步转录服务类"""
PREPROCESS_COMPLETED_PROGRESS = 50
TRANSCRIPTION_COMPLETED_PROGRESS = 100
def __init__(self):
dashscope.api_key = QWEN_API_KEY
self.redis_client = redis.Redis(**REDIS_CONFIG)
self.base_url = APP_CONFIG['base_url']
self.base_url = APP_CONFIG['base_url'].rstrip('/')
@staticmethod
def _create_requests_session(default_timeout: Optional[int] = None) -> requests.Session:
session = _DefaultTimeoutSession(default_timeout=default_timeout)
session.trust_env = os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"}
return session
@staticmethod
def _normalize_dashscope_base_address(endpoint_url: Optional[str]) -> Optional[str]:
if not endpoint_url:
return None
normalized = str(endpoint_url).strip().rstrip("/")
suffix = "/services/audio/asr/transcription"
if normalized.endswith(suffix):
normalized = normalized[: -len(suffix)]
return normalized or None
@staticmethod
def _resolve_dashscope_api_key(audio_config: Optional[Dict[str, Any]] = None) -> str:
api_key = (audio_config or {}).get("api_key")
if isinstance(api_key, str):
api_key = api_key.strip()
if not api_key:
raise Exception("未在启用的 ASR 模型配置中设置 DashScope API Key")
return api_key
def _build_dashscope_request_options(self, audio_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
request_options: Dict[str, Any] = {
"api_key": self._resolve_dashscope_api_key(audio_config),
}
endpoint_url = (audio_config or {}).get("endpoint_url")
base_address = self._normalize_dashscope_base_address(endpoint_url)
if base_address:
request_options["base_address"] = base_address
return request_options
@staticmethod
def _resolve_request_timeout_seconds(audio_config: Optional[Dict[str, Any]] = None) -> int:
value = (audio_config or {}).get("request_timeout_seconds")
try:
timeout_seconds = int(value)
except (TypeError, ValueError):
timeout_seconds = 300
return max(10, timeout_seconds)
def _dashscope_async_call(self, request_options: Dict[str, Any], call_params: Dict[str, Any], timeout_seconds: int):
session = self._create_requests_session(timeout_seconds)
try:
try:
return Transcription.async_call(session=session, **request_options, **call_params)
except TypeError:
return Transcription.async_call(**request_options, **call_params)
finally:
session.close()
def _dashscope_fetch(self, paraformer_task_id: str, request_options: Dict[str, Any], timeout_seconds: int):
session = self._create_requests_session(timeout_seconds)
try:
try:
return Transcription.fetch(task=paraformer_task_id, session=session, **request_options)
except TypeError:
return Transcription.fetch(task=paraformer_task_id, **request_options)
finally:
session.close()
@staticmethod
def _build_dashscope_call_params(audio_config: Dict[str, Any], file_url: str) -> Dict[str, Any]:
model_name = audio_config.get("model") or "paraformer-v2"
call_params: Dict[str, Any] = {
"model": model_name,
"file_urls": [file_url],
}
optional_keys = [
"language_hints",
"disfluency_removal_enabled",
"diarization_enabled",
"speaker_count",
"vocabulary_id",
"timestamp_alignment_enabled",
"channel_id",
"special_word_filter",
"audio_event_detection_enabled",
"phrase_id",
]
for key in optional_keys:
value = audio_config.get(key)
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
if isinstance(value, list) and not value:
continue
call_params[key] = value
return call_params
def test_asr_model(self, audio_config: Dict[str, Any], test_file_url: Optional[str] = None) -> Dict[str, Any]:
provider = str(audio_config.get("provider") or "dashscope").strip().lower()
if provider != "dashscope":
raise Exception(f"当前仅支持 DashScope 音频识别测试,暂不支持供应商: {provider}")
request_options = self._build_dashscope_request_options(audio_config)
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
dashscope.api_key = request_options["api_key"]
target_file_url = (
test_file_url
or "https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav"
)
call_params = self._build_dashscope_call_params(audio_config, target_file_url)
response = self._dashscope_async_call(request_options, call_params, timeout_seconds)
if response.status_code != HTTPStatus.OK:
raise Exception(response.message or "音频模型测试失败")
return {
"provider_task_id": response.output.task_id,
"test_file_url": target_file_url,
"used_params": call_params,
}
def start_transcription(self, meeting_id: int, audio_file_path: str) -> str:
def start_transcription(self, meeting_id: int, audio_file_path: str, business_task_id: Optional[str] = None) -> str:
"""
启动异步转录任务
@ -44,8 +177,14 @@ class AsyncTranscriptionService:
deleted_segments = cursor.rowcount
print(f"Deleted {deleted_segments} old transcript segments")
# 删除旧的转录任务记录
cursor.execute("DELETE FROM transcript_tasks WHERE meeting_id = %s", (meeting_id,))
# 删除旧的转录任务记录;如果已创建本地占位任务,则保留当前任务记录
if business_task_id:
cursor.execute(
"DELETE FROM transcript_tasks WHERE meeting_id = %s AND task_id <> %s",
(meeting_id, business_task_id),
)
else:
cursor.execute("DELETE FROM transcript_tasks WHERE meeting_id = %s", (meeting_id,))
deleted_tasks = cursor.rowcount
print(f"Deleted {deleted_tasks} old transcript tasks")
@ -57,60 +196,117 @@ class AsyncTranscriptionService:
cursor.close()
# 2. 构造完整的文件URL
file_url = f"{self.base_url}{audio_file_path}"
file_url = f"{self.base_url}/{audio_file_path.lstrip('/')}"
# 获取热词表ID (asr_vocabulary_id)
vocabulary_id = SystemConfigService.get_asr_vocabulary_id()
# 获取音频模型配置
audio_config = SystemConfigService.get_active_audio_model_config("asr")
provider = str(audio_config.get("provider") or "dashscope").strip().lower()
if provider != "dashscope":
raise Exception(f"当前仅支持 DashScope 音频识别,暂不支持供应商: {provider}")
print(f"Starting transcription for meeting_id: {meeting_id}, file_url: {file_url}, vocabulary_id: {vocabulary_id}")
request_options = self._build_dashscope_request_options(audio_config)
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
dashscope.api_key = request_options["api_key"]
call_params = self._build_dashscope_call_params(audio_config, file_url)
print(
f"Starting transcription for meeting_id: {meeting_id}, "
f"file_url: {file_url}, model: {call_params.get('model')}, "
f"vocabulary_id: {call_params.get('vocabulary_id')}"
)
# 3. 调用Paraformer异步API
call_params = {
'model': 'paraformer-v2',
'file_urls': [file_url],
'language_hints': ['zh', 'en'],
'disfluency_removal_enabled': True,
'diarization_enabled': True,
'speaker_count': 10
}
if vocabulary_id:
call_params['vocabulary_id'] = vocabulary_id
task_response = Transcription.async_call(**call_params)
task_response = self._dashscope_async_call(request_options, call_params, timeout_seconds)
if task_response.status_code != HTTPStatus.OK:
print(f"Failed to start transcription: {task_response.status_code}, {task_response.message}")
raise Exception(f"Transcription API error: {task_response.message}")
paraformer_task_id = task_response.output.task_id
business_task_id = str(uuid.uuid4())
final_business_task_id = business_task_id or str(uuid.uuid4())
# 4. 在Redis中存储任务映射
current_time = datetime.now().isoformat()
task_data = {
'business_task_id': business_task_id,
'business_task_id': final_business_task_id,
'paraformer_task_id': paraformer_task_id,
'meeting_id': str(meeting_id),
'file_url': file_url,
'status': 'pending',
'progress': '0',
'progress': str(self.PREPROCESS_COMPLETED_PROGRESS),
'created_at': current_time,
'updated_at': current_time
}
# 存储到Redis过期时间24小时
self.redis_client.hset(f"task:{business_task_id}", mapping=task_data)
self.redis_client.expire(f"task:{business_task_id}", 86400)
self.redis_client.hset(f"task:{final_business_task_id}", mapping=task_data)
self.redis_client.expire(f"task:{final_business_task_id}", 86400)
# 5. 在数据库中创建任务记录
self._save_task_to_db(business_task_id, paraformer_task_id, meeting_id, audio_file_path)
self._save_task_to_db(final_business_task_id, paraformer_task_id, meeting_id, audio_file_path)
print(f"Transcription task created: {business_task_id}")
return business_task_id
print(f"Transcription task created: {final_business_task_id}")
return final_business_task_id
except Exception as e:
print(f"Error starting transcription: {e}")
raise e
def create_local_processing_task(
self,
meeting_id: int,
status: str = "processing",
progress: int = 0,
error_message: Optional[str] = None,
) -> str:
business_task_id = str(uuid.uuid4())
current_time = datetime.now().isoformat()
task_data = {
"business_task_id": business_task_id,
"paraformer_task_id": "",
"meeting_id": str(meeting_id),
"status": status,
"progress": str(progress),
"created_at": current_time,
"updated_at": current_time,
"error_message": error_message or "",
}
self.redis_client.hset(f"task:{business_task_id}", mapping=task_data)
self.redis_client.expire(f"task:{business_task_id}", 86400)
with get_db_connection() as connection:
cursor = connection.cursor()
cursor.execute(
"""
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at, error_message)
VALUES (%s, NULL, %s, %s, %s, NOW(), %s)
""",
(business_task_id, meeting_id, status, progress, error_message),
)
connection.commit()
cursor.close()
return business_task_id
def update_local_processing_task(
self,
business_task_id: str,
status: str,
progress: int,
error_message: Optional[str] = None,
) -> None:
updated_at = datetime.now().isoformat()
self.redis_client.hset(
f"task:{business_task_id}",
mapping={
"status": status,
"progress": str(progress),
"updated_at": updated_at,
"error_message": error_message or "",
},
)
self.redis_client.expire(f"task:{business_task_id}", 86400)
self._update_task_status_in_db(business_task_id, status, progress, error_message)
def get_task_status(self, business_task_id: str) -> Dict[str, Any]:
"""
@ -126,52 +322,84 @@ class AsyncTranscriptionService:
current_status = 'failed'
progress = 0
error_message = "An unknown error occurred."
updated_at = datetime.now().isoformat()
status_cache_key = f"task_status_cache:{business_task_id}"
try:
# 1. 获取任务数据优先Redis回源DB
task_data = self._get_task_data(business_task_id)
paraformer_task_id = task_data['paraformer_task_id']
# 2. 查询外部API获取状态
try:
paraformer_response = Transcription.fetch(task=paraformer_task_id)
if paraformer_response.status_code != HTTPStatus.OK:
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
paraformer_status = paraformer_response.output.task_status
current_status = self._map_paraformer_status(paraformer_status)
progress = self._calculate_progress(paraformer_status)
error_message = None #执行成功,清除初始状态
except Exception as e:
current_status = 'failed'
progress = 0
error_message = f"Error fetching status from provider: {e}"
# 直接进入finally块更新状态后返回
return
# 3. 如果任务完成,处理结果
if current_status == 'completed' and paraformer_response.output.get('results'):
# 防止并发处理:先检查数据库中的状态
db_task_status = self._get_task_status_from_db(business_task_id)
if db_task_status != 'completed':
# 只有当数据库中状态不是completed时才处理
# 先将状态更新为completed作为分布式锁
self._update_task_status_in_db(business_task_id, 'completed', 100, None)
try:
self._process_transcription_result(
business_task_id,
int(task_data['meeting_id']),
paraformer_response.output
)
except Exception as e:
current_status = 'failed'
progress = 100 # 进度为100但状态是失败
error_message = f"Error processing transcription result: {e}"
print(error_message)
stored_status = str(task_data.get('status') or '').lower()
if stored_status in {'completed', 'failed'}:
current_status = stored_status
progress = int(task_data.get('progress') or 0)
error_message = task_data.get('error_message') or None
updated_at = task_data.get('updated_at') or updated_at
else:
paraformer_task_id = task_data.get('paraformer_task_id')
if not paraformer_task_id:
current_status = task_data.get('status') or 'processing'
progress = int(task_data.get('progress') or 0)
error_message = task_data.get('error_message') or None
updated_at = task_data.get('updated_at') or updated_at
else:
print(f"Task {business_task_id} already processed, skipping duplicate processing")
cached_status_raw = self.redis_client.hgetall(status_cache_key)
cached_status = self._normalize_redis_mapping(cached_status_raw) if cached_status_raw else {}
if cached_status and cached_status.get('status') in {'pending', 'processing'}:
current_status = cached_status.get('status', 'pending')
progress = int(cached_status.get('progress') or 0)
error_message = cached_status.get('error_message') or None
updated_at = cached_status.get('updated_at') or updated_at
else:
# 2. 查询外部API获取状态
paraformer_response = None
try:
audio_config = SystemConfigService.get_active_audio_model_config("asr")
request_options = self._build_dashscope_request_options(audio_config)
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
dashscope.api_key = request_options["api_key"]
paraformer_response = self._dashscope_fetch(paraformer_task_id, request_options, timeout_seconds)
if paraformer_response.status_code != HTTPStatus.OK:
raise Exception(f"Failed to fetch task status from provider: {paraformer_response.message}")
paraformer_status = paraformer_response.output.task_status
current_status = self._map_paraformer_status(paraformer_status)
progress = self._calculate_progress(paraformer_status)
error_message = None
except Exception as e:
# 云侧状态查询抖动不应直接把任务打成 failed
# 保持当前非终态并等待下一轮轮询重试。
current_status = task_data.get('status') or 'processing'
progress = int(task_data.get('progress') or 0)
error_message = None
print(
f"Transient provider status fetch error for task {business_task_id}: {e}. "
f"Keeping status={current_status}, progress={progress}"
)
# 3. 如果任务完成,处理结果
if (
current_status == 'completed'
and paraformer_response is not None
and paraformer_response.output.get('results')
):
db_task_status = self._get_task_status_from_db(business_task_id)
if db_task_status != 'completed':
self._update_task_status_in_db(business_task_id, 'completed', 100, None)
try:
self._process_transcription_result(
business_task_id,
int(task_data['meeting_id']),
paraformer_response.output
)
except Exception as e:
current_status = 'failed'
progress = 100
error_message = f"Error processing transcription result: {e}"
print(error_message)
else:
print(f"Task {business_task_id} already processed, skipping duplicate processing")
except Exception as e:
error_message = f"Error getting task status: {e}"
@ -192,37 +420,44 @@ class AsyncTranscriptionService:
if error_message:
update_data['error_message'] = error_message
self.redis_client.hset(f"task:{business_task_id}", mapping=update_data)
self.redis_client.hset(status_cache_key, mapping=update_data)
self.redis_client.expire(
status_cache_key,
max(1, int(BACKGROUND_TASK_CONFIG.get('transcription_status_cache_ttl', 3)))
)
# 更新数据库
self._update_task_status_in_db(business_task_id, current_status, progress, error_message)
# 5. 构造并返回最终结果
result = {
'task_id': business_task_id,
'status': current_status,
'progress': progress,
'error_message': error_message,
'updated_at': updated_at,
'meeting_id': None,
'created_at': None,
}
if task_data:
result['meeting_id'] = int(task_data['meeting_id'])
result['created_at'] = task_data.get('created_at')
return result
return self._build_task_status_result(
business_task_id,
task_data,
current_status,
progress,
error_message,
updated_at,
)
def _get_task_data(self, business_task_id: str) -> Dict[str, Any]:
"""从Redis或数据库获取任务数据"""
# 尝试从Redis获取
task_data_bytes = self.redis_client.hgetall(f"task:{business_task_id}")
if task_data_bytes and task_data_bytes.get(b'paraformer_task_id'):
# Redis返回的是bytes需要解码
return {k.decode('utf-8'): v.decode('utf-8') for k, v in task_data_bytes.items()}
task_data_raw = self.redis_client.hgetall(f"task:{business_task_id}")
if task_data_raw:
task_data = self._normalize_redis_mapping(task_data_raw)
if task_data.get('paraformer_task_id'):
return task_data
if str(task_data.get('status') or '').lower() in {'pending', 'processing', 'completed', 'failed'}:
return task_data
# 如果Redis没有从数据库回源
task_data_from_db = self._get_task_from_db(business_task_id)
if not task_data_from_db or not task_data_from_db.get('paraformer_task_id'):
if not task_data_from_db:
raise Exception("Task not found in DB")
if (
not task_data_from_db.get('paraformer_task_id')
and str(task_data_from_db.get('status') or '').lower() not in {'pending', 'processing', 'completed', 'failed'}
):
raise Exception("Task not found in DB or paraformer_task_id is missing")
# 将从DB获取的数据缓存回Redis
@ -296,9 +531,10 @@ class AsyncTranscriptionService:
def _calculate_progress(self, paraformer_status: str) -> int:
"""根据Paraformer状态计算进度"""
progress_mapping = {
'PENDING': 10,
'RUNNING': 50,
'SUCCEEDED': 100,
# 预处理完成后,转录任务进入云侧排队/执行阶段,统一展示为 50。
'PENDING': self.PREPROCESS_COMPLETED_PROGRESS,
'RUNNING': self.PREPROCESS_COMPLETED_PROGRESS,
'SUCCEEDED': self.TRANSCRIPTION_COMPLETED_PROGRESS,
'FAILED': 0
}
return progress_mapping.get(paraformer_status, 0)
@ -308,13 +544,38 @@ class AsyncTranscriptionService:
try:
with get_db_connection() as connection:
cursor = connection.cursor()
# 插入转录任务记录
insert_task_query = """
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at)
VALUES (%s, %s, %s, 'pending', 0, NOW())
"""
cursor.execute(insert_task_query, (business_task_id, paraformer_task_id, meeting_id))
cursor.execute("SELECT task_id FROM transcript_tasks WHERE task_id = %s", (business_task_id,))
existing = cursor.fetchone()
if existing:
cursor.execute(
"""
UPDATE transcript_tasks
SET paraformer_task_id = %s, meeting_id = %s, status = 'pending', progress = %s,
completed_at = NULL, error_message = NULL
WHERE task_id = %s
""",
(
paraformer_task_id,
meeting_id,
self.PREPROCESS_COMPLETED_PROGRESS,
business_task_id,
),
)
else:
insert_task_query = """
INSERT INTO transcript_tasks (task_id, paraformer_task_id, meeting_id, status, progress, created_at)
VALUES (%s, %s, %s, 'pending', %s, NOW())
"""
cursor.execute(
insert_task_query,
(
business_task_id,
paraformer_task_id,
meeting_id,
self.PREPROCESS_COMPLETED_PROGRESS,
),
)
connection.commit()
cursor.close()
@ -376,7 +637,8 @@ class AsyncTranscriptionService:
cursor = connection.cursor(dictionary=True)
query = """
SELECT tt.task_id as business_task_id, tt.paraformer_task_id, tt.meeting_id, tt.status, tt.created_at
SELECT tt.task_id as business_task_id, tt.paraformer_task_id, tt.meeting_id, tt.status, tt.progress,
tt.error_message, tt.created_at, tt.completed_at
FROM transcript_tasks tt
WHERE tt.task_id = %s
"""
@ -391,13 +653,55 @@ class AsyncTranscriptionService:
'paraformer_task_id': result['paraformer_task_id'],
'meeting_id': str(result['meeting_id']),
'status': result['status'],
'created_at': result['created_at'].isoformat() if result['created_at'] else None
'progress': str(result.get('progress') or 0),
'error_message': result.get('error_message') or '',
'created_at': result['created_at'].isoformat() if result['created_at'] else None,
'updated_at': (
result['completed_at'].isoformat()
if result.get('completed_at')
else result['created_at'].isoformat() if result.get('created_at') else None
),
}
return None
except Exception as e:
print(f"Error getting task from database: {e}")
return None
def _normalize_redis_mapping(self, mapping: Dict[Any, Any]) -> Dict[str, Any]:
normalized: Dict[str, Any] = {}
for key, value in mapping.items():
normalized_key = key.decode('utf-8') if isinstance(key, bytes) else str(key)
if isinstance(value, bytes):
normalized_value = value.decode('utf-8')
else:
normalized_value = value
normalized[normalized_key] = normalized_value
return normalized
def _build_task_status_result(
self,
business_task_id: str,
task_data: Optional[Dict[str, Any]],
status: str,
progress: int,
error_message: Optional[str],
updated_at: str
) -> Dict[str, Any]:
result = {
'task_id': business_task_id,
'status': status,
'progress': progress,
'error_message': error_message,
'updated_at': updated_at,
'meeting_id': None,
'created_at': None,
}
if task_data:
meeting_id = task_data.get('meeting_id')
result['meeting_id'] = int(meeting_id) if meeting_id is not None else None
result['created_at'] = task_data.get('created_at')
return result
def _process_transcription_result(self, business_task_id: str, meeting_id: int, paraformer_output: Any):
"""
@ -410,8 +714,14 @@ class AsyncTranscriptionService:
transcription_url = paraformer_output['results'][0]['transcription_url']
print(f"Fetching transcription from URL: {transcription_url}")
response = requests.get(transcription_url)
audio_config = SystemConfigService.get_active_audio_model_config("asr")
timeout_seconds = self._resolve_request_timeout_seconds(audio_config)
session = self._create_requests_session(timeout_seconds)
try:
response = session.get(transcription_url, timeout=timeout_seconds)
finally:
session.close()
response.raise_for_status()
transcription_data = response.json()

View File

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

View File

@ -25,7 +25,9 @@ def handle_audio_upload(
auto_summarize: bool = True,
background_tasks: BackgroundTasks = None,
prompt_id: int = None,
duration: int = 0
model_code: str = None,
duration: int = 0,
transcription_task_id: str = None,
) -> dict:
"""
处理已保存的完整音频文件
@ -44,9 +46,11 @@ def handle_audio_upload(
meeting_id: 会议ID
current_user: 当前用户信息
auto_summarize: 是否自动生成总结默认True
background_tasks: FastAPI 后台任务对象
background_tasks: 为兼容现有调用保留实际后台执行由服务层线程池完成
prompt_id: 提示词模版ID可选如果不指定则使用默认模版
model_code: 总结模型编码可选如果不指定则使用默认模型
duration: 音频时长
transcription_task_id: 预先创建的本地任务ID可选用于异步上传场景
Returns:
dict: {
@ -58,7 +62,7 @@ def handle_audio_upload(
"has_transcription": bool # 原来是否有转录记录 (成功时)
}
"""
print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, Type: {type(prompt_id)}")
print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, model_code: {model_code}")
# 1. 权限和已有文件检查
try:
@ -136,18 +140,22 @@ def handle_audio_upload(
# 4. 启动转录任务
try:
transcription_task_id = transcription_service.start_transcription(meeting_id, file_path)
transcription_task_id = transcription_service.start_transcription(
meeting_id,
file_path,
business_task_id=transcription_task_id,
)
print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}")
# 5. 如果启用自动总结且提供了 background_tasks添加监控任务
if auto_summarize and transcription_task_id and background_tasks:
background_tasks.add_task(
async_meeting_service.monitor_and_auto_summarize,
# 5. 如果启用自动总结,则提交后台监控任务
if auto_summarize and transcription_task_id:
async_meeting_service.enqueue_transcription_monitor(
meeting_id,
transcription_task_id,
prompt_id # 传递 prompt_id 给自动总结监控任务
prompt_id,
model_code
)
print(f"[audio_service] Auto-summarize enabled, monitor task added for meeting {meeting_id}, prompt_id: {prompt_id}")
print(f"[audio_service] Auto-summarize enabled, monitor task scheduled for meeting {meeting_id}, prompt_id: {prompt_id}, model_code: {model_code}")
except Exception as e:
print(f"Failed to start transcription: {e}")

View File

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

View File

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

View File

@ -3,21 +3,27 @@ import redis
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from app.core.config import REDIS_CONFIG
from app.services.system_config_service import SystemConfigService
import os
# JWT配置
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'your-super-secret-key-change-in-production')
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7天
class JWTService:
def __init__(self):
self.redis_client = redis.Redis(**REDIS_CONFIG)
@staticmethod
def _get_access_token_expire_minutes() -> int:
expire_days = SystemConfigService.get_token_expire_days()
return max(1, expire_days) * 24 * 60
def create_access_token(self, data: Dict[str, Any]) -> str:
"""创建JWT访问令牌"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
expire_minutes = self._get_access_token_expire_minutes()
expire = datetime.utcnow() + timedelta(minutes=expire_minutes)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
@ -27,7 +33,7 @@ class JWTService:
if user_id:
self.redis_client.setex(
f"token:{user_id}:{encoded_jwt}",
ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Redis需要秒
expire_minutes * 60, # Redis需要秒
"active"
)
@ -99,4 +105,4 @@ class JWTService:
return self.create_access_token(new_data)
# 全局实例
jwt_service = JWTService()
jwt_service = JWTService()

View File

@ -1,32 +1,312 @@
import json
import dashscope
from http import HTTPStatus
from typing import Optional, Dict, List, Generator, Any
import app.core.config as config_module
import os
from typing import Optional, Dict, Generator, Any, List
import httpx
from app.core.database import get_db_connection
from app.services.system_config_service import SystemConfigService
class LLMServiceError(Exception):
"""LLM 调用失败时抛出的结构化异常。"""
def __init__(self, message: str, *, status_code: Optional[int] = None):
super().__init__(message)
self.message = message
self.status_code = status_code
class LLMService:
"""LLM服务 - 专注于大模型API调用和提示词管理"""
def __init__(self):
# 设置dashscope API key
dashscope.api_key = config_module.QWEN_API_KEY
@staticmethod
def _use_system_proxy() -> bool:
return os.getenv("IMEETING_USE_SYSTEM_PROXY", "").lower() in {"1", "true", "yes", "on"}
@staticmethod
def _create_httpx_client() -> httpx.Client:
return httpx.Client(
trust_env=LLMService._use_system_proxy()
)
@staticmethod
def _coerce_int(value: Any, default: int, minimum: Optional[int] = None) -> int:
try:
normalized = int(value)
except (TypeError, ValueError):
normalized = default
if minimum is not None:
normalized = max(minimum, normalized)
return normalized
@staticmethod
def _coerce_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
@staticmethod
def _build_timeout(timeout_seconds: int) -> httpx.Timeout:
normalized_timeout = max(1, int(timeout_seconds))
connect_timeout = min(10.0, float(normalized_timeout))
return httpx.Timeout(
connect=connect_timeout,
read=float(normalized_timeout),
write=float(normalized_timeout),
pool=connect_timeout,
)
@staticmethod
def _normalize_api_key(api_key: Optional[Any]) -> Optional[str]:
if api_key is None:
return None
normalized = str(api_key).strip()
return normalized or None
@staticmethod
def _normalize_model_code(model_code: Optional[Any]) -> Optional[str]:
if model_code is None:
return None
normalized = str(model_code).strip()
return normalized or None
@classmethod
def build_call_params_from_config(cls, config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
config = config or {}
endpoint_url = str(config.get("endpoint_url") or SystemConfigService.get_llm_endpoint_url() or "").strip()
api_key = cls._normalize_api_key(config.get("api_key"))
if api_key is None:
api_key = cls._normalize_api_key(SystemConfigService.get_llm_api_key())
default_model = SystemConfigService.get_llm_model_name()
default_timeout = SystemConfigService.get_llm_timeout()
default_temperature = SystemConfigService.get_llm_temperature()
default_top_p = SystemConfigService.get_llm_top_p()
default_max_tokens = SystemConfigService.get_llm_max_tokens()
default_system_prompt = SystemConfigService.get_llm_system_prompt(None)
return {
"endpoint_url": endpoint_url,
"api_key": api_key,
"model": str(
config.get("llm_model_name")
or config.get("model")
or config.get("model_name")
or default_model
).strip(),
"timeout": cls._coerce_int(
config.get("llm_timeout")
or config.get("timeout")
or config.get("time_out")
or default_timeout,
default_timeout,
minimum=1,
),
"temperature": cls._coerce_float(
config.get("llm_temperature") if config.get("llm_temperature") is not None else config.get("temperature"),
default_temperature,
),
"top_p": cls._coerce_float(
config.get("llm_top_p") if config.get("llm_top_p") is not None else config.get("top_p"),
default_top_p,
),
"max_tokens": cls._coerce_int(
config.get("llm_max_tokens") or config.get("max_tokens") or default_max_tokens,
default_max_tokens,
minimum=1,
),
"system_prompt": config.get("llm_system_prompt") or config.get("system_prompt") or default_system_prompt,
}
def _get_llm_call_params(self) -> Dict[str, Any]:
"""
获取 dashscope.Generation.call() 所需的参数字典
获取 OpenAI 兼容接口调用参数
Returns:
Dict: 包含 modeltimeouttemperaturetop_p 的参数字典
Dict: 包含 endpoint_urlapi_keymodeltimeouttemperaturetop_pmax_tokens 的参数字典
"""
return {
'model': SystemConfigService.get_llm_model_name(),
'timeout': SystemConfigService.get_llm_timeout(),
'temperature': SystemConfigService.get_llm_temperature(),
'top_p': SystemConfigService.get_llm_top_p(),
return self.build_call_params_from_config()
@staticmethod
def _build_chat_url(endpoint_url: str) -> str:
base_url = (endpoint_url or "").rstrip("/")
if base_url.endswith("/chat/completions"):
return base_url
return f"{base_url}/chat/completions"
@staticmethod
def _build_headers(api_key: Optional[str]) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
def _normalize_messages(
self,
prompt: Optional[str] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> List[Dict[str, str]]:
normalized_messages: List[Dict[str, str]] = []
if messages is not None:
for message in messages:
if not isinstance(message, dict):
continue
role = str(message.get("role") or "").strip()
if not role:
continue
content = self._normalize_content(message.get("content"))
if not content:
continue
normalized_messages.append({"role": role, "content": content})
return normalized_messages
if prompt is not None:
prompt_content = self._normalize_content(prompt)
if prompt_content:
normalized_messages.append({"role": "user", "content": prompt_content})
return normalized_messages
@staticmethod
def _merge_system_messages(
messages: List[Dict[str, str]],
base_system_prompt: Optional[str],
) -> List[Dict[str, str]]:
merged_messages: List[Dict[str, str]] = []
merged_system_parts: List[str] = []
if isinstance(base_system_prompt, str) and base_system_prompt.strip():
merged_system_parts.append(base_system_prompt.strip())
index = 0
while index < len(messages) and messages[index].get("role") == "system":
content = str(messages[index].get("content") or "").strip()
if content:
merged_system_parts.append(content)
index += 1
if merged_system_parts:
merged_messages.append({"role": "system", "content": "\n\n".join(merged_system_parts)})
merged_messages.extend(messages[index:])
return merged_messages
def _build_payload(
self,
prompt: Optional[str] = None,
messages: Optional[List[Dict[str, Any]]] = None,
stream: bool = False,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
params = params or self._get_llm_call_params()
normalized_messages = self._normalize_messages(prompt=prompt, messages=messages)
normalized_messages = self._merge_system_messages(normalized_messages, params.get("system_prompt"))
if not normalized_messages:
raise ValueError("缺少 prompt 或 messages")
payload = {
"model": params["model"],
"messages": normalized_messages,
"temperature": params["temperature"],
"top_p": params["top_p"],
"max_tokens": params["max_tokens"],
"stream": stream,
}
return payload
@staticmethod
def _normalize_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for item in content:
if isinstance(item, str):
texts.append(item)
elif isinstance(item, dict):
text = item.get("text")
if text:
texts.append(text)
return "".join(texts)
return ""
def _extract_response_text(self, data: Dict[str, Any]) -> str:
choices = data.get("choices") or []
if not choices:
return ""
first_choice = choices[0] or {}
message = first_choice.get("message") or {}
content = message.get("content")
if content:
return self._normalize_content(content)
delta = first_choice.get("delta") or {}
delta_content = delta.get("content")
if delta_content:
return self._normalize_content(delta_content)
return ""
def _validate_call_params(self, params: Dict[str, Any]) -> Optional[str]:
if not params.get("endpoint_url"):
return "缺少 endpoint_url"
if not params.get("model"):
return "缺少 model"
if not params.get("api_key"):
return "缺少API Key"
return None
@staticmethod
def _extract_error_message_from_response(response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
payload = None
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
parts = [
str(error.get("message") or "").strip(),
str(error.get("type") or "").strip(),
str(error.get("code") or "").strip(),
]
message = " / ".join(part for part in parts if part)
if message:
return message
if isinstance(error, str) and error.strip():
return error.strip()
message = payload.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
text = (response.text or "").strip()
return text[:500] if text else f"HTTP {response.status_code}"
def get_call_params_by_model_code(self, model_code: Optional[str] = None) -> Dict[str, Any]:
normalized_model_code = self._normalize_model_code(model_code)
if not normalized_model_code:
return self._get_llm_call_params()
runtime_config = SystemConfigService.get_model_runtime_config(normalized_model_code)
if not runtime_config:
raise LLMServiceError(f"指定模型不可用: {normalized_model_code}")
return self.build_call_params_from_config(runtime_config)
def _resolve_call_params(
self,
model_code: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if config is not None:
return self.build_call_params_from_config(config)
return self.get_call_params_by_model_code(model_code)
def get_task_prompt(self, task_type: str, cursor=None, prompt_id: Optional[int] = None) -> str:
"""
@ -38,7 +318,7 @@ class LLMService:
prompt_id: 可选的提示词ID如果指定则使用该提示词否则使用默认提示词
Returns:
str: 提示词内容如果未找到返回默认提示词
str: 提示词内容
"""
# 如果指定了 prompt_id直接获取该提示词
if prompt_id:
@ -74,63 +354,191 @@ class LLMService:
if result:
return result['content']
# 返回默认提示词
return self._get_default_prompt(task_type)
prompt_label = f"ID={prompt_id}" if prompt_id else f"task_type={task_type} 的默认模版"
raise LLMServiceError(f"未找到可用提示词模版:{prompt_label}")
def _get_default_prompt(self, task_name: str) -> str:
"""获取默认提示词"""
system_prompt = config_module.LLM_CONFIG.get("system_prompt", "请根据提供的内容进行总结和分析。")
default_prompts = {
'MEETING_TASK': system_prompt,
'KNOWLEDGE_TASK': "请根据提供的信息生成知识库文章。",
}
return default_prompts.get(task_name, "请根据提供的内容进行总结和分析。")
def _call_llm_api_stream(self, prompt: str) -> Generator[str, None, None]:
"""流式调用阿里Qwen大模型API"""
def stream_llm_api(
self,
prompt: Optional[str] = None,
model_code: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> Generator[str, None, None]:
"""流式调用 OpenAI 兼容大模型API。"""
try:
responses = dashscope.Generation.call(
**self._get_llm_call_params(),
prompt=prompt,
stream=True,
incremental_output=True
)
params = self._resolve_call_params(model_code=model_code, config=config)
validation_error = self._validate_call_params(params)
if validation_error:
yield f"error: {validation_error}"
return
for response in responses:
if response.status_code == HTTPStatus.OK:
# 增量输出内容
new_content = response.output.get('text', '')
if new_content:
yield new_content
else:
error_msg = f"Request failed with status code: {response.status_code}, Error: {response.message}"
print(error_msg)
yield f"error: {error_msg}"
break
timeout = self._build_timeout(params["timeout"])
with self._create_httpx_client() as client:
with client.stream(
"POST",
self._build_chat_url(params["endpoint_url"]),
headers=self._build_headers(params["api_key"]),
json=self._build_payload(prompt=prompt, messages=messages, stream=True, params=params),
timeout=timeout,
) as response:
response.raise_for_status()
for line in response.iter_lines():
if not line or not line.startswith("data:"):
continue
data_line = line[5:].strip()
if not data_line or data_line == "[DONE]":
continue
try:
data = json.loads(data_line)
except json.JSONDecodeError:
continue
new_content = self._extract_response_text(data)
if new_content:
yield new_content
except LLMServiceError as e:
error_msg = e.message or str(e)
print(f"流式调用大模型API错误: {error_msg}")
yield f"error: {error_msg}"
except httpx.HTTPStatusError as e:
detail = self._extract_error_message_from_response(e.response)
error_msg = f"流式调用大模型API错误: HTTP {e.response.status_code} - {detail}"
print(error_msg)
yield f"error: {error_msg}"
except httpx.TimeoutException:
error_msg = f"流式调用大模型API超时: timeout={params['timeout']}s"
print(error_msg)
yield f"error: {error_msg}"
except httpx.RequestError as e:
error_msg = f"流式调用大模型API网络错误: {e}"
print(error_msg)
yield f"error: {error_msg}"
except Exception as e:
error_msg = f"流式调用大模型API错误: {e}"
print(error_msg)
yield f"error: {error_msg}"
def _call_llm_api(self, prompt: str) -> Optional[str]:
"""调用阿里Qwen大模型API非流式"""
def call_llm_api(
self,
prompt: Optional[str] = None,
model_code: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""调用 OpenAI 兼容大模型API非流式"""
try:
response = dashscope.Generation.call(
**self._get_llm_call_params(),
prompt=prompt
return self.call_llm_api_or_raise(
prompt=prompt,
model_code=model_code,
config=config,
messages=messages,
)
if response.status_code == HTTPStatus.OK:
return response.output.get('text', '')
else:
print(f"API调用失败: {response.status_code}, {response.message}")
return None
except Exception as e:
except LLMServiceError as e:
print(f"调用大模型API错误: {e}")
return None
def call_llm_api_or_raise(
self,
prompt: Optional[str] = None,
model_code: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> str:
"""调用 OpenAI 兼容大模型API非流式失败时抛出结构化异常。"""
params = self._resolve_call_params(model_code=model_code, config=config)
return self.call_llm_api_with_config_or_raise(params, prompt=prompt, messages=messages)
def call_llm_api_messages(
self,
messages: List[Dict[str, Any]],
model_code: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""使用多消息结构调用 OpenAI 兼容大模型API非流式"""
return self.call_llm_api(prompt=None, model_code=model_code, config=config, messages=messages)
def call_llm_api_messages_or_raise(
self,
messages: List[Dict[str, Any]],
model_code: Optional[str] = None,
config: Optional[Dict[str, Any]] = None,
) -> str:
"""使用多消息结构调用 OpenAI 兼容大模型API非流式失败时抛出结构化异常。"""
return self.call_llm_api_or_raise(prompt=None, model_code=model_code, config=config, messages=messages)
def call_llm_api_with_config(
self,
params: Dict[str, Any],
prompt: Optional[str] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> Optional[str]:
"""使用指定配置调用 OpenAI 兼容大模型API非流式"""
try:
return self.call_llm_api_with_config_or_raise(params, prompt=prompt, messages=messages)
except LLMServiceError as e:
print(f"调用大模型API错误: {e}")
return None
def call_llm_api_with_config_or_raise(
self,
params: Dict[str, Any],
prompt: Optional[str] = None,
messages: Optional[List[Dict[str, Any]]] = None,
) -> str:
"""使用指定配置调用 OpenAI 兼容大模型API非流式失败时抛出结构化异常。"""
validation_error = self._validate_call_params(params)
if validation_error:
raise LLMServiceError(validation_error)
timeout = self._build_timeout(params["timeout"])
try:
with self._create_httpx_client() as client:
response = client.post(
self._build_chat_url(params["endpoint_url"]),
headers=self._build_headers(params["api_key"]),
json=self._build_payload(prompt=prompt, messages=messages, params=params),
timeout=timeout,
)
response.raise_for_status()
content = self._extract_response_text(response.json())
if content:
return content
raise LLMServiceError("API调用失败: 返回内容为空")
except httpx.HTTPStatusError as e:
detail = self._extract_error_message_from_response(e.response)
raise LLMServiceError(
f"HTTP {e.response.status_code} - {detail}",
status_code=e.response.status_code,
) from e
except httpx.TimeoutException:
raise LLMServiceError(f"调用超时: timeout={params['timeout']}s")
except httpx.RequestError as e:
raise LLMServiceError(f"网络错误: {e}") from e
except Exception as e:
raise LLMServiceError(str(e)) from e
def test_model(self, config: Dict[str, Any], prompt: Optional[str] = None) -> Dict[str, Any]:
params = self.build_call_params_from_config(config)
test_prompt = prompt or "请用一句中文回复LLM测试成功。"
content = self.call_llm_api_with_config_or_raise(params, test_prompt)
if not content:
raise Exception("模型无有效返回内容")
return {
"model": params["model"],
"endpoint_url": params["endpoint_url"],
"response_preview": content[:500],
"used_params": {
"timeout": params["timeout"],
"temperature": params["temperature"],
"top_p": params["top_p"],
"max_tokens": params["max_tokens"],
},
}
# 测试代码
if __name__ == '__main__':

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,32 @@
import json
import time
from threading import RLock
from typing import Optional, Dict, Any
from app.core.database import get_db_connection
class SystemConfigService:
"""系统配置服务 - 从 dict_data 表中读取和保存 system_config 类型的配置"""
"""系统配置服务"""
DICT_TYPE = 'system_config'
PUBLIC_CATEGORY = 'public'
DEFAULT_LLM_ENDPOINT_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
CACHE_TTL_SECONDS = 60
# 配置键常量
ASR_VOCABULARY_ID = 'asr_vocabulary_id'
TIMELINE_PAGESIZE = 'timeline_pagesize'
PAGE_SIZE = 'page_size'
DEFAULT_RESET_PASSWORD = 'default_reset_password'
MAX_AUDIO_SIZE = 'max_audio_size'
MAX_IMAGE_SIZE = 'max_image_size'
TOKEN_EXPIRE_DAYS = 'token_expire_days'
# 品牌配置
APP_NAME = 'app_name'
DEPRECATED_BRANDING_PARAMETER_KEYS = frozenset({
'console_subtitle',
'preview_title',
'login_welcome',
'footer_text',
})
# 声纹配置
VOICEPRINT_TEMPLATE_TEXT = 'voiceprint_template_text'
@ -26,6 +40,273 @@ class SystemConfigService:
LLM_TIMEOUT = 'llm_timeout'
LLM_TEMPERATURE = 'llm_temperature'
LLM_TOP_P = 'llm_top_p'
_cache_lock = RLock()
_config_cache: Dict[str, tuple[float, Any]] = {}
_category_cache: Dict[str, tuple[float, Dict[str, Any]]] = {}
_all_configs_cache: tuple[float, Dict[str, Any]] | None = None
BUILTIN_PARAMETERS = [
{
"param_key": TOKEN_EXPIRE_DAYS,
"param_name": "Token过期时间",
"param_value": "7",
"value_type": "number",
"category": "system",
"description": "控制登录 token 的过期时间,单位:天。",
"is_active": 1,
},
{
"param_key": DEFAULT_RESET_PASSWORD,
"param_name": "默认重置密码",
"param_value": "123456",
"value_type": "string",
"category": "system",
"description": "管理员重置用户密码时使用的默认密码。",
"is_active": 1,
},
{
"param_key": PAGE_SIZE,
"param_name": "系统分页大小",
"param_value": "10",
"value_type": "number",
"category": PUBLIC_CATEGORY,
"description": "系统通用分页数量。",
"is_active": 1,
},
{
"param_key": MAX_AUDIO_SIZE,
"param_name": "音频上传大小限制",
"param_value": "100",
"value_type": "number",
"category": PUBLIC_CATEGORY,
"description": "音频上传大小限制单位MB。",
"is_active": 1,
},
{
"param_key": MAX_IMAGE_SIZE,
"param_name": "图片上传大小限制",
"param_value": "10",
"value_type": "number",
"category": PUBLIC_CATEGORY,
"description": "图片上传大小限制单位MB。",
"is_active": 1,
},
{
"param_key": APP_NAME,
"param_name": "系统名称",
"param_value": "iMeeting",
"value_type": "string",
"category": PUBLIC_CATEGORY,
"description": "前端应用标题。",
"is_active": 1,
},
]
@classmethod
def _is_cache_valid(cls, cached_at: float) -> bool:
return (time.time() - cached_at) < cls.CACHE_TTL_SECONDS
@classmethod
def _get_cached_config(cls, cache_key: str) -> Any:
with cls._cache_lock:
cached = cls._config_cache.get(cache_key)
if not cached:
return None
cached_at, value = cached
if not cls._is_cache_valid(cached_at):
cls._config_cache.pop(cache_key, None)
return None
return value
@classmethod
def _set_cached_config(cls, cache_key: str, value: Any) -> None:
with cls._cache_lock:
cls._config_cache[cache_key] = (time.time(), value)
@classmethod
def invalidate_cache(cls) -> None:
with cls._cache_lock:
cls._config_cache.clear()
cls._category_cache.clear()
cls._all_configs_cache = None
@staticmethod
def _parse_json_object(value: Any) -> Dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return dict(value)
if isinstance(value, str):
value = value.strip()
if not value:
return {}
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
@staticmethod
def _normalize_string_list(value: Any) -> Optional[list[str]]:
if value is None:
return None
if isinstance(value, list):
items = [str(item).strip() for item in value if str(item).strip()]
return items or None
if isinstance(value, str):
items = [item.strip() for item in value.split(",") if item.strip()]
return items or None
return None
@classmethod
def _build_audio_runtime_config(cls, audio_row: Dict[str, Any]) -> Dict[str, Any]:
cfg: Dict[str, Any] = {}
if not audio_row:
return cfg
extra_config = cls._parse_json_object(audio_row.get("extra_config"))
if audio_row.get("endpoint_url"):
cfg["endpoint_url"] = audio_row["endpoint_url"]
if audio_row.get("api_key"):
cfg["api_key"] = audio_row["api_key"]
if audio_row.get("provider"):
cfg["provider"] = audio_row["provider"]
if audio_row.get("model_code"):
cfg["model_code"] = audio_row["model_code"]
if audio_row.get("audio_scene"):
cfg["audio_scene"] = audio_row["audio_scene"]
if audio_row.get("hot_word_group_id") is not None:
cfg["hot_word_group_id"] = audio_row["hot_word_group_id"]
if audio_row.get("request_timeout_seconds") is not None:
cfg["request_timeout_seconds"] = int(audio_row["request_timeout_seconds"])
language_hints = cls._normalize_string_list(extra_config.get("language_hints"))
if language_hints is not None:
extra_config["language_hints"] = language_hints
cfg.update(extra_config)
return cfg
@classmethod
def get_active_audio_model_config(cls, scene: str = "asr") -> Dict[str, Any]:
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key,
request_timeout_seconds, hot_word_group_id, extra_config
FROM audio_model_config
WHERE audio_scene = %s AND is_active = 1
ORDER BY is_default DESC, updated_at DESC, config_id ASC
LIMIT 1
""",
(scene,),
)
row = cursor.fetchone()
cursor.close()
return cls._build_audio_runtime_config(row) if row else {}
except Exception:
return {}
@classmethod
def _get_parameter_value(cls, param_key: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT param_value
FROM sys_system_parameters
WHERE param_key = %s AND is_active = 1
LIMIT 1
""",
(param_key,),
)
result = cursor.fetchone()
cursor.close()
return result["param_value"] if result else None
except Exception:
return None
@classmethod
def _get_model_config_json(cls, model_code: str):
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
# 1) llm 专表
cursor.execute(
"""
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout,
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
FROM llm_model_config
WHERE model_code = %s AND is_active = 1
ORDER BY is_default DESC, config_id ASC
LIMIT 1
""",
(model_code,),
)
llm_row = cursor.fetchone()
if not llm_row and model_code == "llm_model":
cursor.execute(
"""
SELECT model_code, endpoint_url, api_key, llm_model_name, llm_timeout,
llm_temperature, llm_top_p, llm_max_tokens, llm_system_prompt
FROM llm_model_config
WHERE is_active = 1
ORDER BY is_default DESC, updated_at DESC, config_id ASC
LIMIT 1
"""
)
llm_row = cursor.fetchone()
if llm_row:
cursor.close()
cfg = {}
if llm_row.get("endpoint_url"):
cfg["endpoint_url"] = llm_row["endpoint_url"]
if llm_row.get("api_key"):
cfg["api_key"] = llm_row["api_key"]
if llm_row.get("llm_model_name") is not None:
cfg["model_name"] = llm_row["llm_model_name"]
if llm_row.get("llm_timeout") is not None:
cfg["time_out"] = llm_row["llm_timeout"]
if llm_row.get("llm_temperature") is not None:
cfg["temperature"] = float(llm_row["llm_temperature"])
if llm_row.get("llm_top_p") is not None:
cfg["top_p"] = float(llm_row["llm_top_p"])
if llm_row.get("llm_max_tokens") is not None:
cfg["max_tokens"] = llm_row["llm_max_tokens"]
if llm_row.get("llm_system_prompt") is not None:
cfg["system_prompt"] = llm_row["llm_system_prompt"]
return cfg
# 2) audio 专表
if model_code in ("audio_model", "voiceprint_model"):
target_scene = "voiceprint" if model_code == "voiceprint_model" else "asr"
cursor.close()
audio_cfg = cls.get_active_audio_model_config(target_scene)
return audio_cfg or None
cursor.execute(
"""
SELECT model_code, model_name, audio_scene, provider, endpoint_url, api_key,
request_timeout_seconds, hot_word_group_id, extra_config
FROM audio_model_config
WHERE model_code = %s AND is_active = 1
ORDER BY is_default DESC, config_id ASC
LIMIT 1
""",
(model_code,),
)
audio_row = cursor.fetchone()
cursor.close()
if audio_row:
return cls._build_audio_runtime_config(audio_row)
return None
except Exception:
return None
@classmethod
def get_config(cls, dict_code: str, default_value: Any = None) -> Any:
@ -39,31 +320,18 @@ class SystemConfigService:
Returns:
配置项的值
"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT extension_attr
FROM dict_data
WHERE dict_type = %s AND dict_code = %s AND status = 1
LIMIT 1
"""
cursor.execute(query, (cls.DICT_TYPE, dict_code))
result = cursor.fetchone()
cursor.close()
cached_value = cls._get_cached_config(dict_code)
if cached_value is not None:
return cached_value
if result and result['extension_attr']:
try:
ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr']
return ext_attr.get('value', default_value)
except (json.JSONDecodeError, AttributeError):
pass
# 1) 新参数表
value = cls._get_parameter_value(dict_code)
if value is not None:
cls._set_cached_config(dict_code, value)
return value
return default_value
except Exception as e:
print(f"Error getting config {dict_code}: {e}")
return default_value
cls._set_cached_config(dict_code, default_value)
return default_value
@classmethod
def get_config_attribute(cls, dict_code: str, attr_name: str, default_value: Any = None) -> Any:
@ -80,31 +348,17 @@ class SystemConfigService:
Returns:
属性值
"""
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT extension_attr
FROM dict_data
WHERE dict_type = %s AND dict_code = %s AND status = 1
LIMIT 1
"""
cursor.execute(query, (cls.DICT_TYPE, dict_code))
result = cursor.fetchone()
cursor.close()
# 1) 新模型配置表
model_json = cls._get_model_config_json(dict_code)
if model_json is not None:
return model_json.get(attr_name, default_value)
if result and result['extension_attr']:
try:
ext_attr = json.loads(result['extension_attr']) if isinstance(result['extension_attr'], str) else result['extension_attr']
return ext_attr.get(attr_name, default_value)
except (json.JSONDecodeError, AttributeError):
pass
return default_value
return default_value
except Exception as e:
print(f"Error getting config attribute {dict_code}.{attr_name}: {e}")
return default_value
@classmethod
def get_model_runtime_config(cls, model_code: str) -> Optional[Dict[str, Any]]:
"""获取模型运行时配置,优先从新模型配置表读取。"""
return cls._get_model_config_json(model_code)
@classmethod
def set_config(cls, dict_code: str, value: Any, label_cn: str = None) -> bool:
@ -119,47 +373,70 @@ class SystemConfigService:
Returns:
是否设置成功
"""
# 1) 优先写入新参数表
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
# 检查配置是否存在
cursor.execute(
"SELECT id FROM dict_data WHERE dict_type = %s AND dict_code = %s",
(cls.DICT_TYPE, dict_code)
"""
INSERT INTO sys_system_parameters
(param_key, param_name, param_value, value_type, category, description, is_active)
VALUES (%s, %s, %s, %s, %s, %s, 1)
ON DUPLICATE KEY UPDATE
param_name = VALUES(param_name),
param_value = VALUES(param_value),
value_type = VALUES(value_type),
category = VALUES(category),
description = VALUES(description),
is_active = 1
""",
(
dict_code,
label_cn or dict_code,
str(value) if value is not None else "",
"string",
"system",
"Migrated from legacy system_config",
),
)
existing = cursor.fetchone()
extension_attr = json.dumps({"value": value}, ensure_ascii=False)
if existing:
# 更新现有配置
update_query = """
UPDATE dict_data
SET extension_attr = %s, update_time = NOW()
WHERE dict_type = %s AND dict_code = %s
"""
cursor.execute(update_query, (extension_attr, cls.DICT_TYPE, dict_code))
else:
# 插入新配置
if not label_cn:
label_cn = dict_code
insert_query = """
INSERT INTO dict_data (
dict_type, dict_code, parent_code, label_cn,
extension_attr, status, sort_order
) VALUES (%s, %s, 'ROOT', %s, %s, 1, 0)
"""
cursor.execute(insert_query, (cls.DICT_TYPE, dict_code, label_cn, extension_attr))
if dict_code == cls.ASR_VOCABULARY_ID:
cursor.execute(
"""
INSERT INTO audio_model_config
(model_code, model_name, audio_scene, provider, request_timeout_seconds, extra_config, description, is_active, is_default)
VALUES (
'audio_model',
'音频识别模型',
'asr',
'dashscope',
300,
JSON_OBJECT(
'model', 'paraformer-v2',
'vocabulary_id', %s,
'speaker_count', 10,
'language_hints', JSON_ARRAY('zh', 'en'),
'disfluency_removal_enabled', TRUE,
'diarization_enabled', TRUE
),
'语音识别模型配置',
1,
1
)
ON DUPLICATE KEY UPDATE
extra_config = JSON_SET(COALESCE(extra_config, JSON_OBJECT()), '$.vocabulary_id', %s),
is_active = 1
""",
(str(value), str(value)),
)
conn.commit()
cursor.close()
cls.invalidate_cache()
return True
except Exception as e:
print(f"Error setting config {dict_code}: {e}")
return False
print(f"Error setting config in sys_system_parameters {dict_code}: {e}")
return False
@classmethod
def get_all_configs(cls) -> Dict[str, Any]:
@ -169,34 +446,64 @@ class SystemConfigService:
Returns:
配置字典 {dict_code: value}
"""
with cls._cache_lock:
if cls._all_configs_cache and cls._is_cache_valid(cls._all_configs_cache[0]):
return dict(cls._all_configs_cache[1])
if cls._all_configs_cache and not cls._is_cache_valid(cls._all_configs_cache[0]):
cls._all_configs_cache = None
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
query = """
SELECT dict_code, label_cn, extension_attr
FROM dict_data
WHERE dict_type = %s AND status = 1
ORDER BY sort_order
"""
cursor.execute(query, (cls.DICT_TYPE,))
results = cursor.fetchall()
cursor.execute(
"""
SELECT param_key, param_value
FROM sys_system_parameters
WHERE is_active = 1
ORDER BY category, param_key
"""
)
rows = cursor.fetchall()
cursor.close()
configs = {}
for row in results:
if row['extension_attr']:
try:
ext_attr = json.loads(row['extension_attr']) if isinstance(row['extension_attr'], str) else row['extension_attr']
configs[row['dict_code']] = ext_attr.get('value')
except (json.JSONDecodeError, AttributeError):
configs[row['dict_code']] = None
else:
configs[row['dict_code']] = None
return configs
if rows:
configs = {row["param_key"]: row["param_value"] for row in rows}
with cls._cache_lock:
cls._all_configs_cache = (time.time(), configs)
return dict(configs)
except Exception as e:
print(f"Error getting all configs: {e}")
print(f"Error getting all configs from sys_system_parameters: {e}")
return {}
@classmethod
def get_configs_by_category(cls, category: str) -> Dict[str, Any]:
"""按分类获取启用中的参数配置。"""
with cls._cache_lock:
cached = cls._category_cache.get(category)
if cached and cls._is_cache_valid(cached[0]):
return dict(cached[1])
if cached and not cls._is_cache_valid(cached[0]):
cls._category_cache.pop(category, None)
try:
with get_db_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(
"""
SELECT param_key, param_value
FROM sys_system_parameters
WHERE is_active = 1 AND category = %s
ORDER BY param_key
""",
(category,),
)
rows = cursor.fetchall()
cursor.close()
configs = {row["param_key"]: row["param_value"] for row in rows} if rows else {}
with cls._cache_lock:
cls._category_cache[category] = (time.time(), configs)
return dict(configs)
except Exception as e:
print(f"Error getting configs by category {category}: {e}")
return {}
@classmethod
@ -216,22 +523,55 @@ class SystemConfigService:
success = False
return success
@classmethod
def ensure_builtin_parameters(cls) -> None:
"""确保内建系统参数存在,避免后台参数页缺少关键配置项。"""
try:
with get_db_connection() as conn:
cursor = conn.cursor()
for item in cls.BUILTIN_PARAMETERS:
cursor.execute(
"""
INSERT INTO sys_system_parameters
(param_key, param_name, param_value, value_type, category, description, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
param_name = param_name
""",
(
item["param_key"],
item["param_name"],
item["param_value"],
item["value_type"],
item["category"],
item["description"],
item["is_active"],
),
)
conn.commit()
cursor.close()
except Exception as e:
print(f"Error ensuring builtin parameters: {e}")
# 便捷方法:获取特定配置
@classmethod
def get_asr_vocabulary_id(cls) -> Optional[str]:
"""获取ASR热词字典ID"""
return cls.get_config(cls.ASR_VOCABULARY_ID)
"""获取ASR热词字典ID — 优先从 audio_model_config.hot_word_group_id → hot_word_group.vocabulary_id"""
audio_cfg = cls.get_active_audio_model_config("asr")
if audio_cfg.get("vocabulary_id"):
return audio_cfg["vocabulary_id"]
return cls.get_config_attribute('audio_model', 'vocabulary_id')
# 声纹配置获取方法(直接使用通用方法)
@classmethod
def get_voiceprint_template(cls, default: str = "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。") -> str:
"""获取声纹采集模版"""
return cls.get_config_attribute('voiceprint', 'template_text', default)
return cls.get_config_attribute('voiceprint_model', 'template_text', default)
@classmethod
def get_voiceprint_max_size(cls, default: int = 5242880) -> int:
"""获取声纹文件大小限制 (bytes), 默认5MB"""
value = cls.get_config_attribute('voiceprint', 'voiceprint_max_size', default)
value = cls.get_config_attribute('voiceprint_model', 'max_size_bytes', default)
try:
return int(value)
except (ValueError, TypeError):
@ -240,7 +580,7 @@ class SystemConfigService:
@classmethod
def get_voiceprint_duration(cls, default: int = 12) -> int:
"""获取声纹采集最短时长 (秒)"""
value = cls.get_config_attribute('voiceprint', 'duration_seconds', default)
value = cls.get_config_attribute('voiceprint_model', 'duration_seconds', default)
try:
return int(value)
except (ValueError, TypeError):
@ -249,7 +589,7 @@ class SystemConfigService:
@classmethod
def get_voiceprint_sample_rate(cls, default: int = 16000) -> int:
"""获取声纹采样率"""
value = cls.get_config_attribute('voiceprint', 'sample_rate', default)
value = cls.get_config_attribute('voiceprint_model', 'sample_rate', default)
try:
return int(value)
except (ValueError, TypeError):
@ -258,34 +598,98 @@ class SystemConfigService:
@classmethod
def get_voiceprint_channels(cls, default: int = 1) -> int:
"""获取声纹通道数"""
value = cls.get_config_attribute('voiceprint', 'channels', default)
value = cls.get_config_attribute('voiceprint_model', 'channels', default)
try:
return int(value)
except (ValueError, TypeError):
return default
@classmethod
def get_timeline_pagesize(cls, default: int = 10) -> int:
"""获取会议时间轴每页数量"""
value = cls.get_config(cls.TIMELINE_PAGESIZE, str(default))
def get_page_size(cls) -> int:
"""获取系统通用分页数量。"""
value = cls.get_config(cls.PAGE_SIZE)
if value is None:
raise RuntimeError("系统参数 page_size 缺失")
try:
return int(value)
except (ValueError, TypeError):
return default
raise RuntimeError(f"系统参数 page_size 非法: {value!r}") from None
@classmethod
def get_default_reset_password(cls, default: str = "111111") -> str:
def get_default_reset_password(cls) -> str:
"""获取默认重置密码"""
return cls.get_config(cls.DEFAULT_RESET_PASSWORD, default)
value = cls.get_config(cls.DEFAULT_RESET_PASSWORD)
if value is None:
raise RuntimeError("系统参数 default_reset_password 缺失")
normalized = str(value).strip()
if not normalized:
raise RuntimeError("系统参数 default_reset_password 不能为空")
return normalized
@classmethod
def get_max_audio_size(cls, default: int = 100) -> int:
def get_max_audio_size(cls) -> int:
"""获取上传音频文件大小限制MB"""
value = cls.get_config(cls.MAX_AUDIO_SIZE, str(default))
value = cls.get_config(cls.MAX_AUDIO_SIZE)
if value is None:
raise RuntimeError("系统参数 max_audio_size 缺失")
try:
return int(value)
except (ValueError, TypeError):
return default
raise RuntimeError(f"系统参数 max_audio_size 非法: {value!r}") from None
@classmethod
def get_max_image_size(cls) -> int:
"""获取上传图片大小限制MB"""
value = cls.get_config(cls.MAX_IMAGE_SIZE)
if value is None:
raise RuntimeError("系统参数 max_image_size 缺失")
try:
return int(value)
except (ValueError, TypeError):
raise RuntimeError(f"系统参数 max_image_size 非法: {value!r}") from None
@classmethod
def get_token_expire_days(cls) -> int:
"""获取访问 token 过期时间(天)。"""
value = cls.get_config(cls.TOKEN_EXPIRE_DAYS)
if value is None:
raise RuntimeError("系统参数 token_expire_days 缺失")
try:
normalized = int(value)
except (ValueError, TypeError):
raise RuntimeError(f"系统参数 token_expire_days 非法: {value!r}") from None
if normalized <= 0:
raise RuntimeError(f"系统参数 token_expire_days 非法: {value!r}")
return normalized
@classmethod
def get_public_configs(cls) -> Dict[str, Any]:
"""获取提供给前端初始化使用的公开参数。"""
cls.ensure_builtin_parameters()
public_configs = cls.get_configs_by_category(cls.PUBLIC_CATEGORY)
required_keys = [
cls.APP_NAME,
cls.PAGE_SIZE,
cls.MAX_AUDIO_SIZE,
cls.MAX_IMAGE_SIZE,
]
missing_keys = [key for key in required_keys if str(public_configs.get(key) or "").strip() == ""]
if missing_keys:
raise RuntimeError(f"公开系统参数缺失: {', '.join(missing_keys)}")
page_size = cls.get_page_size()
max_audio_size_mb = cls.get_max_audio_size()
max_image_size_mb = cls.get_max_image_size()
return {
"app_name": str(public_configs[cls.APP_NAME]).strip(),
"page_size": str(page_size),
"PAGE_SIZE": page_size,
"max_audio_size": str(max_audio_size_mb),
"MAX_FILE_SIZE": max_audio_size_mb * 1024 * 1024,
"max_image_size": str(max_image_size_mb),
"MAX_IMAGE_SIZE": max_image_size_mb * 1024 * 1024,
}
# LLM模型配置获取方法直接使用通用方法
@classmethod
@ -319,3 +723,33 @@ class SystemConfigService:
return float(value)
except (ValueError, TypeError):
return default
@classmethod
def get_llm_max_tokens(cls, default: int = 2048) -> int:
"""获取LLM最大输出token"""
value = cls.get_config_attribute('llm_model', 'max_tokens', default)
try:
return int(value)
except (ValueError, TypeError):
return default
@classmethod
def get_llm_system_prompt(cls, default: Optional[str] = None) -> Optional[str]:
"""获取LLM系统提示词"""
value = cls.get_config_attribute('llm_model', 'system_prompt', default)
return value if isinstance(value, str) and value.strip() else default
@classmethod
def get_llm_endpoint_url(cls, default: str = DEFAULT_LLM_ENDPOINT_URL) -> str:
"""获取LLM服务Base API"""
value = cls.get_config_attribute('llm_model', 'endpoint_url', default)
return value if isinstance(value, str) and value.strip() else default
@classmethod
def get_llm_api_key(cls, default: Optional[str] = None) -> Optional[str]:
"""获取LLM服务API Key"""
value = cls.get_config_attribute('llm_model', 'api_key', default)
if value is None:
return default
value_str = str(value).strip()
return value_str or default

View File

@ -49,9 +49,9 @@ class TerminalService:
cu.caption as current_user_caption,
dd.label_cn as terminal_type_name
FROM terminals t
LEFT JOIN users u ON t.created_by = u.user_id
LEFT JOIN users cu ON t.current_user_id = cu.user_id
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
LEFT JOIN sys_users u ON t.created_by = u.user_id
LEFT JOIN sys_users cu ON t.current_user_id = cu.user_id
LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
WHERE {where_clause}
ORDER BY t.created_at DESC
LIMIT %s OFFSET %s
@ -75,8 +75,8 @@ class TerminalService:
u.username as creator_username,
dd.label_cn as terminal_type_name
FROM terminals t
LEFT JOIN users u ON t.created_by = u.user_id
LEFT JOIN dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
LEFT JOIN sys_users u ON t.created_by = u.user_id
LEFT JOIN sys_dict_data dd ON t.terminal_type = dd.dict_code AND dd.dict_type = 'terminal_type'
WHERE t.id = %s
"""
cursor.execute(query, (terminal_id,))
@ -105,14 +105,17 @@ class TerminalService:
query = """
INSERT INTO terminals (
imei, terminal_name, terminal_type, description, status, created_by
) VALUES (%s, %s, %s, %s, %s, %s)
imei, terminal_name, terminal_type, description,
firmware_version, mac_address, status, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(query, (
terminal_data.imei,
terminal_data.terminal_name,
terminal_data.terminal_type,
terminal_data.description,
terminal_data.firmware_version,
terminal_data.mac_address,
terminal_data.status,
user_id
))

View File

@ -3,15 +3,16 @@
用于解析音频文件的元数据信息如时长采样率编码格式等
"""
from tinytag import TinyTag
import json
import shutil
import subprocess
def get_audio_duration(file_path: str) -> int:
"""
获取音频文件时长
使用TinyTag读取音频文件时长
使用 ffprobe 读取音频时长
Args:
file_path: 音频文件的完整路径
@ -26,13 +27,33 @@ def get_audio_duration(file_path: str) -> int:
- WAV (.wav)
- OGG (.ogg)
- FLAC (.flac)
- 以及TinyTag支持的其他音频格式
- 以及 ffprobe 支持的其他音频格式
"""
ffprobe_path = shutil.which("ffprobe")
if not ffprobe_path:
return 0
try:
tag = TinyTag.get(file_path)
if tag.duration and tag.duration > 0:
return int(tag.duration)
completed = subprocess.run(
[
ffprobe_path,
"-v",
"error",
"-print_format",
"json",
"-show_format",
str(file_path),
],
check=False,
capture_output=True,
text=True,
)
if completed.returncode == 0 and completed.stdout:
payload = json.loads(completed.stdout)
duration_value = (payload.get("format") or {}).get("duration")
if duration_value:
return int(float(duration_value))
except Exception as e:
print(f"获取音频时长失败 ({file_path}): {e}")
print(f"ffprobe 获取音频时长失败 ({file_path}): {e}")
return 0

View File

@ -1,6 +1,7 @@
# Core Application Framework
fastapi
uvicorn
mcp
# Database & Cache
mysql-connector-python
@ -8,6 +9,7 @@ redis
# Services & External APIs
requests
httpx
dashscope
PyJWT
qiniu
@ -22,6 +24,4 @@ psutil
# APK Parsing
pyaxmlparser
# Audio Metadata
tinytag
python-dotenv

View File

@ -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` - 本说明文档

View File

@ -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='专用终端设备表';

View File

@ -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';

View File

@ -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;

View File

@ -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 表中的记录可能被删除,我们希望保留历史任务记录

View File

@ -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';

View File

@ -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
);

View File

@ -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);

View File

@ -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

View File

@ -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 '选用提示词ID0表示未指定',
`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`;

View File

@ -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;

File diff suppressed because one or more lines are too long

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 '使用的提示词模版ID0表示未使用或使用默认模版'
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 '使用的提示词模版ID0表示未使用或使用默认模版'
AFTER tags;
-- 为 knowledge_bases 表添加索引
ALTER TABLE knowledge_bases
ADD INDEX idx_prompt_id (prompt_id);
-- ============================================
-- 验证修改
-- ============================================
-- 查看 meetings 表结构
-- DESCRIBE meetings;
-- 查看 knowledge_bases 表结构
-- DESCRIBE knowledge_bases;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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访问存在问题")

View File

@ -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}")

View File

@ -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>

View File

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

View File

@ -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=== 调试完成 ===")

View File

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

View File

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

View File

@ -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❌ 七牛云连接测试失败!")

View File

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

View File

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

View File

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

View File

@ -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请在网页上验证用户是否自动登出")

View File

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

View File

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

View File

@ -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("测试完成")

View File

@ -176,10 +176,11 @@
| `task_id` | VARCHAR(100) | PRIMARY KEY | 业务任务唯一ID (UUID) |
| `meeting_id` | INT | NOT NULL, FK | 关联的会议ID |
| `prompt_id` | INT | NOT NULL, FK | 关联的提示词模版ID |
| `model_code` | VARCHAR(100) | NULL | 本次任务使用的模型编码,用于服务恢复和任务重试时复用原模型 |
| `user_prompt` | TEXT | NULL | 用户输入的额外提示 |
| `status` | ENUM(...) | DEFAULT 'pending' | 任务状态: 'pending', 'processing', 'completed', 'failed' |
| `progress` | INT | DEFAULT 0 | 任务进度百分比 (0-100) |
| `result` | TEXT | NULL | 成功时存储生成的纪要内容 |
| `result` | TEXT | NULL | 成功时存储导出的 Markdown 文件路径(如 `/uploads/...` |
| `created_at` | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 任务创建时间 |
| `completed_at` | TIMESTAMP | NULL | 任务完成时间 |
| `error_message` | TEXT | NULL | 错误信息记录 |
@ -433,4 +434,4 @@ erDiagram
meetings ||--|{ meeting_summaries : "has"
meetings ||--|{ llm_tasks : "has"
```
```

View File

@ -2,4 +2,11 @@
# 组件
+ 数据库 mysql 5.7+ 10.100.51.51:3306 root | Unis@123
+ 缓存 redis 6.2 10.100.51.51:6379 Unis@123
+ 缓存 redis 6.2 10.100.51.51:6379 Unis@123
# 升级前确认
+ 后端运行环境需提供 `ffmpeg``ffprobe`
+ 本次数据库升级包含 `backend/sql/migrations/cleanup_audio_model_config_and_drop_legacy_ai_tables.sql`
+ 本次数据库升级还需执行 `backend/sql/migrations/normalize_llm_task_result_paths.sql`
+ 升级后 `audio_model_config` 将新增 `request_timeout_seconds`,并清理旧的 ASR/声纹冗余列
+ 升级后 `llm_tasks.result` 将统一为 `/uploads/...` 相对路径,与音频文件路径保持一致

View File

@ -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. 执行基线
后续所有新增功能、重构与代码审计,均以本文档为默认判断依据:
- 先判断职责边界是否正确
- 再判断依赖方向是否健康
- 再判断是否需要拆分或合并
- 最后才考虑目录美观、文件长短和风格一致性
当“看起来更模块化”和“真实可读、可改、可验证”发生冲突时,优先后者。

View File

@ -52,7 +52,7 @@ services:
--maxmemory 2gb
--maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD:-${REDIS_PASSWORD:-Unis@123}}\" ping | grep PONG"]
interval: 10s
timeout: 3s
retries: 5
@ -93,9 +93,6 @@ services:
API_HOST: 0.0.0.0
API_PORT: 8000
BASE_URL: ${BASE_URL:-http://localhost}
# LLM配置
QWEN_API_KEY: ${QWEN_API_KEY}
# 后端不直接暴露端口通过nginx代理访问
ports:
- "${BACKEND_PORT:-8000}:8000"

View File

@ -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

132
frontend/.gitignore vendored
View File

@ -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.*

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>iMeeting - 智能会议助手</title>
<title>智听云平台</title>
</head>
<body>
<div id="root"></div>

View File

@ -33,6 +33,64 @@ server {
proxy_send_timeout 300s;
}
# MCP Streamable HTTP 代理
# 对外以 /mcp 作为标准入口,避免 MCP Client 处理尾斜杠重定向。
location = /mcp {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding on;
}
location = /mcp/ {
return 308 /mcp;
}
location = /docs {
proxy_pass http://backend:8000/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /redoc {
proxy_pass http://backend:8000/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /openapi.json {
proxy_pass http://backend:8000/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /docs/oauth2-redirect {
proxy_pass http://backend:8000/docs/oauth2-redirect;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 上传文件代理 (使用 ^~ 提高优先级,避免被静态文件正则匹配拦截)
location ^~ /uploads/ {
proxy_pass http://backend:8000;
@ -57,4 +115,4 @@ server {
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.6",
@ -19,8 +20,7 @@
"axios": "^1.6.2",
"canvg": "^4.0.3",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.2",
"lucide-react": "^0.294.0",
"jspdf": "^4.2.1",
"markmap-common": "^0.18.9",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12",

View File

@ -7,12 +7,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: 'MiSans', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f8fafc;
background-color: transparent;
color: #1e293b;
line-height: 1.6;
}
@ -22,6 +20,347 @@ body {
width: 100%;
}
.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 12px;
font-weight: 600;
letter-spacing: 0.01em;
transition: box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, color 0.22s ease;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
}
.ant-btn:hover {
transform: none;
}
.ant-btn:active {
transform: none;
}
.ant-btn .anticon {
font-size: 0.98em;
}
.ant-btn.ant-btn-default,
.ant-btn.ant-btn-dashed {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(148, 163, 184, 0.2);
color: #294261;
}
.ant-btn.ant-btn-default:hover,
.ant-btn.ant-btn-dashed:hover {
background: #ffffff;
border-color: rgba(59, 130, 246, 0.28);
color: #1d4ed8;
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.1);
}
.ant-btn.ant-btn-primary {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 45%, #1e40af 100%);
border-color: transparent;
color: #fff;
}
.ant-btn.ant-btn-primary:hover {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 48%, #1d4ed8 100%);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.18);
}
.ant-btn.ant-btn-primary.ant-btn-dangerous,
.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 48%, #b91c1c 100%);
border-color: transparent;
color: #fff;
}
.ant-btn.ant-btn-primary.ant-btn-dangerous:hover,
.ant-btn.ant-btn-dangerous:not(.ant-btn-link):not(.ant-btn-text):hover {
box-shadow: 0 10px 20px rgba(220, 38, 38, 0.18);
}
.ant-btn.ant-btn-link,
.ant-btn.ant-btn-text {
box-shadow: none;
transform: none;
}
.ant-btn.ant-btn-link {
padding-inline: 6px;
color: #31568b;
}
.ant-btn.ant-btn-link:hover {
color: #1d4ed8;
background: rgba(37, 99, 235, 0.08);
}
.ant-btn.ant-btn-link.ant-btn-dangerous,
.ant-btn.ant-btn-text.ant-btn-dangerous {
color: #dc2626;
}
.ant-btn.ant-btn-link.ant-btn-dangerous:hover,
.ant-btn.ant-btn-text.ant-btn-dangerous:hover {
background: rgba(220, 38, 38, 0.08);
color: #b91c1c;
}
.ant-btn.btn-soft-blue,
.ant-btn.ant-btn-primary.btn-soft-blue {
background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%);
border-color: #bfdbfe;
color: #1d4ed8;
box-shadow: 0 10px 22px rgba(59, 130, 246, 0.12);
}
.ant-btn.btn-soft-blue:hover,
.ant-btn.ant-btn-primary.btn-soft-blue:hover {
background: linear-gradient(180deg, #eff6ff 0%, #dbeafe 100%);
border-color: #93c5fd;
color: #1d4ed8;
box-shadow: 0 10px 20px rgba(59, 130, 246, 0.14);
}
.ant-btn.btn-soft-violet,
.ant-btn.ant-btn-primary.btn-soft-violet {
background: linear-gradient(180deg, #faf5ff 0%, #f3e8ff 100%);
border-color: #d8b4fe;
color: #7c3aed;
box-shadow: 0 10px 22px rgba(124, 58, 237, 0.12);
}
.ant-btn.btn-soft-violet:hover,
.ant-btn.ant-btn-primary.btn-soft-violet:hover {
background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 100%);
border-color: #c084fc;
color: #6d28d9;
box-shadow: 0 10px 20px rgba(124, 58, 237, 0.14);
}
.ant-btn.btn-soft-green,
.ant-btn.ant-btn-primary.btn-soft-green {
background: linear-gradient(180deg, #f0fdf4 0%, #dcfce7 100%);
border-color: #86efac;
color: #15803d;
box-shadow: 0 10px 22px rgba(34, 197, 94, 0.12);
}
.ant-btn.btn-soft-green:hover,
.ant-btn.ant-btn-primary.btn-soft-green:hover {
background: linear-gradient(180deg, #dcfce7 0%, #bbf7d0 100%);
border-color: #4ade80;
color: #166534;
box-shadow: 0 10px 20px rgba(34, 197, 94, 0.14);
}
.ant-btn.btn-soft-red,
.ant-btn.ant-btn-primary.btn-soft-red {
background: linear-gradient(180deg, #fff7f7 0%, #fff1f2 100%);
border-color: #fecdd3;
color: #dc2626;
box-shadow: 0 10px 22px rgba(239, 68, 68, 0.1);
}
.ant-btn.btn-soft-red:hover,
.ant-btn.ant-btn-primary.btn-soft-red:hover {
background: linear-gradient(180deg, #fff1f2 0%, #ffe4e6 100%);
border-color: #fda4af;
color: #b91c1c;
box-shadow: 0 10px 20px rgba(239, 68, 68, 0.12);
}
.ant-btn.btn-pill-primary {
height: 40px;
padding-inline: 16px;
border-radius: 999px;
box-shadow: 0 10px 18px rgba(37, 99, 235, 0.18);
}
.ant-btn.ant-btn-lg.btn-pill-primary {
height: 44px;
padding-inline: 18px;
}
.ant-btn.btn-pill-secondary {
height: 40px;
padding-inline: 16px;
border-radius: 999px;
border-color: #dbe3ef;
background: #fff;
color: #49627f;
box-shadow: none;
}
.ant-btn.btn-pill-secondary:hover,
.ant-btn.btn-pill-secondary:focus {
border-color: #c7d4e4;
background: #f8fafc;
color: #355171;
box-shadow: none;
}
.ant-btn.ant-btn-lg.btn-pill-secondary {
height: 44px;
padding-inline: 18px;
}
.ant-card-hoverable {
transition: box-shadow 0.22s ease, border-color 0.22s ease, background 0.22s ease, transform 0.22s ease;
}
.ant-card-hoverable:hover {
transform: none !important;
box-shadow: 0 12px 24px rgba(31, 78, 146, 0.12);
}
.ant-btn.ant-btn-link.btn-text-view,
.ant-btn.ant-btn-text.btn-text-view,
.ant-btn.ant-btn-link.btn-text-neutral,
.ant-btn.ant-btn-text.btn-text-neutral,
.ant-btn.ant-btn-link.btn-text-edit,
.ant-btn.ant-btn-text.btn-text-edit,
.ant-btn.ant-btn-link.btn-text-accent,
.ant-btn.ant-btn-text.btn-text-accent,
.ant-btn.ant-btn-link.btn-text-delete,
.ant-btn.ant-btn-text.btn-text-delete,
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete,
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete {
border: 1px solid #d7e4f6;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88), 0 2px 8px rgba(25, 63, 121, 0.06);
}
.ant-btn.ant-btn-link.btn-text-view,
.ant-btn.ant-btn-text.btn-text-view {
color: #2563eb;
border-color: #c9ddfb;
}
.ant-btn.ant-btn-link.btn-text-view:hover,
.ant-btn.ant-btn-text.btn-text-view:hover {
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
border-color: #a9c9fa;
color: #1d4ed8;
}
.ant-btn.ant-btn-link.btn-text-neutral,
.ant-btn.ant-btn-text.btn-text-neutral {
color: #5f7392;
border-color: #d7e4f6;
}
.ant-btn.ant-btn-link.btn-text-neutral:hover,
.ant-btn.ant-btn-text.btn-text-neutral:hover {
background: linear-gradient(180deg, #fbfdff 0%, #f1f6fc 100%);
border-color: #bfd0e6;
color: #355171;
}
.ant-btn.ant-btn-link.btn-text-edit,
.ant-btn.ant-btn-text.btn-text-edit {
color: #1d4ed8;
border-color: #c9ddfb;
}
.ant-btn.ant-btn-link.btn-text-edit:hover,
.ant-btn.ant-btn-text.btn-text-edit:hover {
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
border-color: #a9c9fa;
color: #1d4ed8;
}
.ant-btn.ant-btn-link.btn-text-accent,
.ant-btn.ant-btn-text.btn-text-accent {
color: #7c3aed;
border-color: #dbc8fb;
}
.ant-btn.ant-btn-link.btn-text-accent:hover,
.ant-btn.ant-btn-text.btn-text-accent:hover {
background: linear-gradient(180deg, #fbf8ff 0%, #f3ebff 100%);
border-color: #caa6fb;
color: #6d28d9;
}
.ant-btn.ant-btn-link.btn-text-delete,
.ant-btn.ant-btn-text.btn-text-delete,
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete,
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete {
color: #dc2626;
border-color: #fecdd3;
}
.ant-btn.ant-btn-link.btn-text-delete:hover,
.ant-btn.ant-btn-text.btn-text-delete:hover,
.ant-btn.ant-btn-link.ant-btn-dangerous.btn-text-delete:hover,
.ant-btn.ant-btn-text.ant-btn-dangerous.btn-text-delete:hover {
background: linear-gradient(180deg, #ffffff 0%, #fff5f5 100%);
border-color: #fda4af;
color: #b91c1c;
}
.ant-btn.ant-btn-icon-only.ant-btn-text,
.ant-btn.ant-btn-icon-only.ant-btn-link {
min-width: 36px;
}
.ant-btn-icon-only {
min-width: 40px;
}
.ant-btn.btn-action-text-lg {
min-height: 32px;
padding-inline: 12px;
border-radius: 999px;
font-size: 13px;
gap: 6px;
}
.ant-btn.btn-action-text-sm,
.ant-btn.btn-action-inline {
min-height: 26px;
padding-inline: 10px;
border-radius: 999px;
font-size: 12px;
gap: 6px;
}
.ant-btn.btn-action-icon-lg,
.ant-btn.btn-action-icon-sm,
.ant-btn.btn-action-compact {
gap: 0;
}
.ant-btn.btn-action-icon-lg.ant-btn-icon-only {
width: 32px;
min-width: 32px;
height: 32px;
padding-inline: 0;
border-radius: 12px;
}
.ant-btn.btn-action-icon-sm.ant-btn-icon-only,
.ant-btn.btn-action-compact.ant-btn-icon-only {
width: 26px;
min-width: 26px;
height: 26px;
padding-inline: 0;
border-radius: 10px;
}
.ant-btn.btn-action-icon-lg .anticon {
font-size: 15px;
}
.ant-btn.btn-action-icon-sm .anticon,
.ant-btn.btn-action-compact .anticon {
font-size: 13px;
}
.app-loading {
display: flex;
flex-direction: column;
@ -83,7 +422,7 @@ body {
}
.btn:hover {
transform: translateY(-1px);
transform: none;
}
.btn-primary {
@ -147,4 +486,4 @@ body {
.text-gray-500 { color: #64748b; }
.text-gray-600 { color: #475569; }
.text-gray-700 { color: #334155; }
.text-gray-900 { color: #0f172a; }
.text-gray-900 { color: #0f172a; }

Some files were not shown because too many files have changed in this diff Show More