From de289add81431130485e98d16f8b488397bdb48c Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Thu, 11 Dec 2025 16:48:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 10244 -> 10244 bytes Dockerfile | 2 +- IMPLEMENTATION_SUMMARY.md | 167 +++++ KB_PROMPT_ID_IMPLEMENTATION_SUMMARY.md | 139 +++++ app.zip | Bin 68653 -> 84838 bytes app/api/endpoints/admin.py | 184 ++++++ app/api/endpoints/admin_dashboard.py | 398 ++++++++++++ app/api/endpoints/audio.py | 568 ++++++++++++++++++ app/api/endpoints/client_downloads.py | 42 +- app/api/endpoints/knowledge_base.py | 1 + app/api/endpoints/meetings.py | 285 +++------ app/api/endpoints/prompts.py | 137 ++++- app/api/endpoints/users.py | 59 +- app/core/config.py | 1 + main.py => app/main.py | 24 +- app/models/models.py | 28 + app/services/async_knowledge_base_service.py | 62 +- app/services/async_meeting_service.py | 77 ++- app/services/audio_service.py | 172 ++++++ app/services/llm_service.py | 37 +- sql/README_terminal_update.md | 201 +++++++ sql/add_dedicated_terminal.sql | 59 ++ sql/add_menu_permissions_system.sql | 99 +++ sql/add_prompt_id_to_llm_tasks.sql | 11 + sql/imeeting.sql | 6 +- sql/migrate_prompts_table.sql | 67 +++ .../add_prompt_id_to_main_tables.sql | 33 + sql/transcript_tasks_setup.sql | 53 ++ test/test_kb_prompt_id_feature.py | 166 +++++ test/test_menu_permissions.py | 89 +++ test/test_prompt_id_feature.py | 176 ++++++ 31 files changed, 3018 insertions(+), 325 deletions(-) create mode 100644 IMPLEMENTATION_SUMMARY.md create mode 100644 KB_PROMPT_ID_IMPLEMENTATION_SUMMARY.md create mode 100644 app/api/endpoints/admin_dashboard.py create mode 100644 app/api/endpoints/audio.py rename main.py => app/main.py (73%) create mode 100644 app/services/audio_service.py create mode 100644 sql/README_terminal_update.md create mode 100644 sql/add_dedicated_terminal.sql create mode 100644 sql/add_menu_permissions_system.sql create mode 100644 sql/add_prompt_id_to_llm_tasks.sql create mode 100644 sql/migrate_prompts_table.sql create mode 100644 sql/migrations/add_prompt_id_to_main_tables.sql create mode 100644 sql/transcript_tasks_setup.sql create mode 100644 test/test_kb_prompt_id_feature.py create mode 100644 test/test_menu_permissions.py create mode 100644 test/test_prompt_id_feature.py diff --git a/.DS_Store b/.DS_Store index acab3771c171a88e9802cabad656a375c25b6a95..d8f9254bc8d3b226237242817f57091f2d7ff8a1 100644 GIT binary patch delta 426 zcmZn(XbG6$I9U^hRb(qtY1gURbe8bnPDjC2%?OpI%F6sipkEDd!O%uI|XJBu2t zF*77G6fhK|6es5-<>%)x00C5}EVw8yCqFM8D8e||RX~A_aW#ewjDPz4PgC~P8 zLjZ#hgDa4BW$UuPX>P=JDwq!ArvU?3ls}th-A=X$Yn_J%*jtq%E?ax8X~~J zzyvaP-hVIvvKScD7`z#rfI0(!I{d+AhX8q=3@%_2%`OE(mQ7|8k)IqORLjQf4m5kt v +``` + +返回: +```json +{ + "code": "200", + "message": "获取启用模版列表成功", + "data": { + "prompts": [ + {"id": 1, "name": "默认会议总结", "is_default": true}, + {"id": 5, "name": "产品会议总结", "is_default": false} + ] + } +} +``` + +### 2. 手动生成总结时指定模版 +```bash +POST /api/meetings/123/generate-summary-async +Authorization: Bearer +Content-Type: application/json + +{ + "user_prompt": "重点关注技术讨论", + "prompt_id": 5 +} +``` + +### 3. 上传音频时指定模版 +```bash +POST /api/meetings/upload-audio +Authorization: Bearer +Content-Type: multipart/form-data + +- audio_file: +- meeting_id: 123 +- auto_summarize: true +- prompt_id: 5 +``` + +## 文件变更列表 + +1. `app/api/endpoints/prompts.py` - 新增API接口 +2. `app/api/endpoints/meetings.py` - 修改两个端点 +3. `app/services/llm_service.py` - 修改get_task_prompt方法 +4. `app/services/async_meeting_service.py` - 修改4个方法 +5. `app/services/audio_service.py` - 修改handle_audio_upload方法 +6. `sql/add_prompt_id_to_llm_tasks.sql` - 数据库迁移脚本 +7. `test_prompt_id_feature.py` - 测试脚本 + +## 注意事项 + +1. prompt_id 会与 task_type 一起验证,防止使用错误类型的模版 +2. 如果指定的 prompt_id 不存在或未启用,会自动使用默认模版 +3. 历史任务记录保留 prompt_id,即使对应的提示词被删除 +4. Redis 中 prompt_id 存储为字符串,使用时需转换为 int diff --git a/KB_PROMPT_ID_IMPLEMENTATION_SUMMARY.md b/KB_PROMPT_ID_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..cbc9505 --- /dev/null +++ b/KB_PROMPT_ID_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,139 @@ +# 知识库提示词模版选择功能实现总结 + +## 功能概述 +为知识库生成功能添加了提示词模版选择支持,用户在创建知识库时可以选择使用的生成模版。 + +## 实现的功能点 + +### 1. 修改请求模型 ✅ +**文件**: `app/models/models.py` +- `CreateKnowledgeBaseRequest` 模型增加 `prompt_id` 字段 +- 类型:Optional[int] = None +- 不指定时使用默认模版 + +### 2. 修改知识库异步服务 ✅ +**文件**: `app/services/async_knowledge_base_service.py` + +#### 2.1 修改任务创建 +- `start_generation()`: 增加 `prompt_id` 参数 +- 将 prompt_id 存储到 Redis 和数据库 +- 支持通过 cursor 参数直接插入(事务场景) + +#### 2.2 修改任务处理 +- `_process_task()`: 从 Redis 读取 prompt_id,传递给 `_build_prompt()` +- 处理空字符串情况,转换为 None + +#### 2.3 修改提示词构建 +- `_build_prompt()`: 增加 `prompt_id` 参数 +- 调用 `llm_service.get_task_prompt('KNOWLEDGE_TASK', prompt_id=prompt_id)` +- 支持获取指定模版或默认模版 + +#### 2.4 修改数据库保存 +- `_save_task_to_db()`: 增加 `prompt_id` 参数 +- 插入时包含 prompt_id 字段 + +### 3. 修改API接口 ✅ +**文件**: `app/api/endpoints/knowledge_base.py` +- `create_knowledge_base`: 从请求中获取 `prompt_id` +- 调用 `async_kb_service.start_generation()` 时传递 `prompt_id` + +### 4. 数据库字段 ✅ +- `knowledge_base_tasks` 表已包含 `prompt_id` 列(用户已添加) +- 类型:int +- 可空:NO(默认值:0) + +## 数据流向 + +``` +前端 → POST /api/knowledge-bases (prompt_id) + → CreateKnowledgeBaseRequest (prompt_id) + → async_kb_service.start_generation(prompt_id) + → 存储到 Redis 和 DB (knowledge_base_tasks.prompt_id) + → _process_task() 读取 prompt_id + → _build_prompt(prompt_id) + → llm_service.get_task_prompt('KNOWLEDGE_TASK', prompt_id) + → 获取指定模版或默认模版 +``` + +## 向后兼容性 + +所有新增的 `prompt_id` 参数都是可选的(Optional[int] = None),确保: +1. 不传递 prompt_id 时,自动使用默认模版 +2. 现有代码无需修改即可正常工作 +3. 数据库中 prompt_id 有默认值 0 + +## 测试结果 + +执行 `test_kb_prompt_id_feature.py` 测试脚本,所有测试通过: +- ✅ 获取启用的知识库提示词列表 (3个模版) +- ✅ 通过prompt_id获取提示词内容 +- ✅ 获取默认提示词(不指定prompt_id) +- ✅ 验证方法签名支持prompt_id参数 +- ✅ 验证数据库schema包含prompt_id列 +- ✅ 验证API模型定义正确 + +## 使用示例 + +### 1. 获取启用的知识库任务模版列表 +```bash +GET /api/prompts/active/KNOWLEDGE_TASK +Authorization: Bearer +``` + +返回: +```json +{ + "code": "200", + "message": "获取启用模版列表成功", + "data": { + "prompts": [ + {"id": 2, "name": "默认知识库生成", "is_default": true}, + {"id": 13, "name": "分析总结模版", "is_default": false} + ] + } +} +``` + +### 2. 创建知识库时指定模版 +```bash +POST /api/knowledge-bases +Authorization: Bearer +Content-Type: application/json + +{ + "title": "产品会议知识库", + "is_shared": false, + "user_prompt": "重点提取产品功能相关信息", + "source_meeting_ids": "1,2,3", + "tags": "产品,功能", + "prompt_id": 13 +} +``` + +## 文件变更列表 + +1. `app/models/models.py` - 修改CreateKnowledgeBaseRequest模型 +2. `app/services/async_knowledge_base_service.py` - 修改5个方法 +3. `app/api/endpoints/knowledge_base.py` - 修改create_knowledge_base端点 +4. `test_kb_prompt_id_feature.py` - 测试脚本 + +## 与会议总结功能的一致性 + +知识库的实现与会议总结功能保持一致: +- 相同的prompt_id传递机制 +- 相同的Redis存储格式(字符串) +- 相同的数据库字段类型 +- 相同的向后兼容策略 +- 相同的验证逻辑(task_type + is_active) + +## 注意事项 + +1. prompt_id 会与 task_type='KNOWLEDGE_TASK' 一起验证 +2. 如果指定的 prompt_id 不存在或未启用,会自动使用默认模版 +3. 历史任务记录保留 prompt_id,即使对应的提示词被删除 +4. Redis 中 prompt_id 存储为字符串,使用时需转换为 int +5. 数据库 prompt_id 默认值为 0(表示未指定) + +## 总结 + +知识库提示词模版选择功能已完全实现并通过测试,与会议总结功能保持一致的设计和实现方式。用户现在可以在创建知识库时选择不同的生成模版,以满足不同场景的需求。 diff --git a/app.zip b/app.zip index 37c3f908c8898be95cedf49d8e1b73294bacebd5..4bb133b2d05fc78305ee4d8b8678b2e926f877a9 100644 GIT binary patch delta 51961 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 F)7WEkIMd(Kb=nue$%@OpJvtoJ_m8c2HCHhit~f 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

%!u4uKAL! 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 diff --git a/app/api/endpoints/admin.py b/app/api/endpoints/admin.py index 45271e7..e350b71 100644 --- a/app/api/endpoints/admin.py +++ b/app/api/endpoints/admin.py @@ -2,7 +2,10 @@ from fastapi import APIRouter, Depends from app.core.auth import get_current_admin_user, get_current_user from app.core.config import LLM_CONFIG, DEFAULT_RESET_PASSWORD, MAX_FILE_SIZE, VOICEPRINT_CONFIG, TIMELINE_PAGESIZE 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 pydantic import BaseModel +from typing import List import json from pathlib import Path @@ -117,3 +120,184 @@ def load_system_config(): print(f"系统配置加载成功: model={config.get('model_name')}, pagesize={config.get('TIMELINE_PAGESIZE')}") except Exception as e: print(f"加载系统配置失败,使用默认配置: {e}") + +# ========== 菜单权限管理接口 ========== + +@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() + + 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.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) + + # 查询所有角色及其权限数量 + 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.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) + + # 检查角色是否存在 + 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) +): + """ + 更新指定角色的菜单权限 + 只有管理员才能访问 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 检查角色是否存在 + cursor.execute("SELECT role_id FROM roles WHERE role_id = %s", (role_id,)) + if not cursor.fetchone(): + return create_api_response(code="404", message="角色不存在") + + # 验证所有menu_id是否有效 + if request.menu_ids: + format_strings = ','.join(['%s'] * len(request.menu_ids)) + cursor.execute( + f"SELECT COUNT(*) as count FROM menus WHERE menu_id IN ({format_strings})", + tuple(request.menu_ids) + ) + valid_count = cursor.fetchone()['count'] + if valid_count != len(request.menu_ids): + return create_api_response(code="400", message="包含无效的菜单ID") + + # 删除该角色的所有现有权限 + cursor.execute("DELETE FROM role_menu_permissions WHERE role_id = %s", (role_id,)) + + # 插入新的权限 + if request.menu_ids: + insert_values = [(role_id, menu_id) for menu_id in request.menu_ids] + cursor.executemany( + "INSERT INTO role_menu_permissions (role_id, menu_id) VALUES (%s, %s)", + insert_values + ) + + connection.commit() + + return create_api_response( + code="200", + message="更新角色权限成功", + data={"role_id": role_id, "menu_count": len(request.menu_ids)} + ) + except Exception as e: + return create_api_response(code="500", message=f"更新角色权限失败: {str(e)}") + +@router.get("/menus/user") +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)}") + diff --git a/app/api/endpoints/admin_dashboard.py b/app/api/endpoints/admin_dashboard.py new file mode 100644 index 0000000..79dc4ed --- /dev/null +++ b/app/api/endpoints/admin_dashboard.py @@ -0,0 +1,398 @@ +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 + +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)}") + + +@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)}") + + +@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)}") + + +@router.get("/admin/tasks/monitor") +async def monitor_tasks( + task_type: str = Query('all', description="任务类型: all, transcription, summary, knowledge_base"), + status: str = Query('all', description="任务状态: all, running, completed, failed"), + 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)}") + + +@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)}") diff --git a/app/api/endpoints/audio.py b/app/api/endpoints/audio.py new file mode 100644 index 0000000..9ab8a16 --- /dev/null +++ b/app/api/endpoints/audio.py @@ -0,0 +1,568 @@ +from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, BackgroundTasks +from app.core.database import get_db_connection +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 pydantic import BaseModel +from typing import Optional, List +from datetime import datetime, timedelta +import os +import uuid +import shutil +import json +import re +from pathlib import Path + +router = APIRouter() +transcription_service = AsyncTranscriptionService() + +# 临时上传目录 - 放在项目目录下 +TEMP_UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +# 配置常量 +MAX_CHUNK_SIZE = 2 * 1024 * 1024 # 2MB per chunk +MAX_TOTAL_SIZE = 500 * 1024 * 1024 # 500MB total +MAX_DURATION = 3600 # 1 hour max recording +SESSION_EXPIRE_HOURS = 1 # 会话1小时后过期 + +# 支持的音频格式 +SUPPORTED_MIME_TYPES = { + 'audio/webm;codecs=opus': '.webm', + 'audio/webm': '.webm', + 'audio/ogg;codecs=opus': '.ogg', + 'audio/mp4': '.m4a', + 'audio/mpeg': '.mp3' +} + + +# ============ Pydantic Models ============ + +class InitUploadRequest(BaseModel): + meeting_id: int + mime_type: str + estimated_duration: Optional[int] = None # 预计时长(秒) + + +class CompleteUploadRequest(BaseModel): + session_id: str + meeting_id: int + total_chunks: int + mime_type: str + auto_transcribe: bool = True + auto_summarize: bool = True + prompt_id: Optional[int] = None # 提示词模版ID(可选) + + +class CancelUploadRequest(BaseModel): + session_id: str + + +# ============ 工具函数 ============ + +def validate_session_id(session_id: str) -> str: + """验证session_id格式,防止路径注入攻击""" + if not re.match(r'^sess_\d+_[a-zA-Z0-9]+$', session_id): + raise ValueError("Invalid session_id format") + return session_id + + +def validate_mime_type(mime_type: str) -> str: + """验证MIME类型是否支持""" + if mime_type not in SUPPORTED_MIME_TYPES: + raise ValueError(f"Unsupported MIME type: {mime_type}") + return SUPPORTED_MIME_TYPES[mime_type] + + +def get_session_dir(session_id: str) -> Path: + """获取会话目录路径""" + validate_session_id(session_id) + return TEMP_UPLOAD_DIR / session_id + + +def get_session_metadata_path(session_id: str) -> Path: + """获取会话metadata文件路径""" + return get_session_dir(session_id) / "metadata.json" + + +def create_session_metadata(session_id: str, meeting_id: int, mime_type: str, user_id: int) -> dict: + """创建会话metadata""" + now = datetime.now() + expires_at = now + timedelta(hours=SESSION_EXPIRE_HOURS) + + metadata = { + "session_id": session_id, + "meeting_id": meeting_id, + "user_id": user_id, + "mime_type": mime_type, + "total_chunks": None, + "received_chunks": [], + "created_at": now.isoformat(), + "expires_at": expires_at.isoformat() + } + + return metadata + + +def save_session_metadata(session_id: str, metadata: dict): + """保存会话metadata""" + metadata_path = get_session_metadata_path(session_id) + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, ensure_ascii=False, indent=2) + + +def load_session_metadata(session_id: str) -> dict: + """加载会话metadata""" + metadata_path = get_session_metadata_path(session_id) + if not metadata_path.exists(): + raise FileNotFoundError(f"Session {session_id} not found") + + with open(metadata_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def update_session_chunks(session_id: str, chunk_index: int): + """更新已接收的分片列表""" + metadata = load_session_metadata(session_id) + + if chunk_index not in metadata['received_chunks']: + metadata['received_chunks'].append(chunk_index) + metadata['received_chunks'].sort() + + save_session_metadata(session_id, metadata) + + +def get_session_total_size(session_id: str) -> int: + """获取会话已上传的总大小""" + session_dir = get_session_dir(session_id) + total_size = 0 + + if session_dir.exists(): + for chunk_file in session_dir.glob("chunk_*.webm"): + total_size += chunk_file.stat().st_size + + return total_size + + +def merge_audio_chunks(session_id: str, meeting_id: int, total_chunks: int, mime_type: str) -> str: + """合并音频分片""" + session_dir = get_session_dir(session_id) + + # 1. 验证分片完整性 + missing = [] + for i in range(total_chunks): + chunk_path = session_dir / f"chunk_{i:04d}.webm" + if not chunk_path.exists(): + missing.append(i) + + if missing: + raise ValueError(f"Missing chunks: {missing}") + + # 2. 创建输出目录 + meeting_audio_dir = AUDIO_DIR / str(meeting_id) + meeting_audio_dir.mkdir(parents=True, exist_ok=True) + + # 3. 生成输出文件名 + file_extension = validate_mime_type(mime_type) + output_filename = f"{uuid.uuid4()}{file_extension}" + output_path = meeting_audio_dir / output_filename + + # 4. 按序合并分片 + with open(output_path, 'wb') as outfile: + for i in range(total_chunks): + chunk_path = session_dir / f"chunk_{i:04d}.webm" + with open(chunk_path, 'rb') as infile: + outfile.write(infile.read()) + + # 5. 清理临时文件 + shutil.rmtree(session_dir) + + # 返回相对路径 + return f"/{output_path.relative_to(BASE_DIR)}" + + +def cleanup_session(session_id: str): + """清理会话文件""" + session_dir = get_session_dir(session_id) + if session_dir.exists(): + shutil.rmtree(session_dir) + + +def cleanup_expired_sessions(): + """清理过期的会话(可以由定时任务调用)""" + now = datetime.now() + cleaned_count = 0 + + if not TEMP_UPLOAD_DIR.exists(): + return cleaned_count + + for session_dir in TEMP_UPLOAD_DIR.iterdir(): + if not session_dir.is_dir(): + continue + + metadata_path = session_dir / "metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + expires_at = datetime.fromisoformat(metadata['expires_at']) + if now > expires_at: + shutil.rmtree(session_dir) + cleaned_count += 1 + print(f"Cleaned up expired session: {session_dir.name}") + except Exception as e: + print(f"Error cleaning up session {session_dir.name}: {e}") + + return cleaned_count + + +# ============ API Endpoints ============ + +@router.post("/audio/stream/init") +async def init_upload_session( + request: InitUploadRequest, + current_user: dict = Depends(get_current_user) +): + """ + 初始化音频流式上传会话 + + 创建临时目录,生成session_id,返回给客户端用于后续分片上传 + """ + try: + # 1. 验证会议是否存在且属于当前用户 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT user_id FROM meetings WHERE meeting_id = %s", + (request.meeting_id,) + ) + meeting = cursor.fetchone() + + if not meeting: + return create_api_response( + code="404", + message="会议不存在" + ) + + if meeting['user_id'] != current_user['user_id']: + return create_api_response( + code="403", + message="无权限操作此会议" + ) + + # 2. 验证MIME类型 + try: + validate_mime_type(request.mime_type) + except ValueError as e: + return create_api_response( + code="400", + message=str(e) + ) + + # 3. 生成session_id + timestamp = int(datetime.now().timestamp() * 1000) + random_str = uuid.uuid4().hex[:8] + session_id = f"sess_{timestamp}_{random_str}" + + # 4. 创建会话目录 + session_dir = get_session_dir(session_id) + session_dir.mkdir(parents=True, exist_ok=True) + + # 5. 创建并保存metadata + metadata = create_session_metadata( + session_id=session_id, + meeting_id=request.meeting_id, + mime_type=request.mime_type, + user_id=current_user['user_id'] + ) + save_session_metadata(session_id, metadata) + + # 6. 清理过期会话 + cleanup_expired_sessions() + + return create_api_response( + code="200", + message="上传会话初始化成功", + data={ + "session_id": session_id, + "chunk_size": MAX_CHUNK_SIZE, + "max_chunks": 1000 + } + ) + + except Exception as e: + print(f"Error initializing upload session: {e}") + return create_api_response( + code="500", + message=f"初始化上传会话失败: {str(e)}" + ) + + +@router.post("/audio/stream/chunk") +async def upload_audio_chunk( + session_id: str = Form(...), + chunk_index: int = Form(...), + chunk: UploadFile = File(...), + current_user: dict = Depends(get_current_user) +): + """ + 上传音频分片 + + 接收并保存音频分片文件 + """ + try: + # 1. 验证session_id格式 + try: + validate_session_id(session_id) + except ValueError: + return create_api_response( + code="400", + message="Invalid session_id format" + ) + + # 2. 加载session metadata + try: + metadata = load_session_metadata(session_id) + except FileNotFoundError: + return create_api_response( + code="404", + message="Session not found" + ) + + # 3. 验证会话所有权 + if metadata['user_id'] != current_user['user_id']: + return create_api_response( + code="403", + message="Permission denied" + ) + + # 4. 验证分片大小 + chunk_data = await chunk.read() + if len(chunk_data) > MAX_CHUNK_SIZE: + return create_api_response( + code="400", + message=f"Chunk size exceeds {MAX_CHUNK_SIZE // (1024*1024)}MB limit" + ) + + # 5. 验证总大小 + session_total = get_session_total_size(session_id) + if session_total + len(chunk_data) > MAX_TOTAL_SIZE: + return create_api_response( + code="400", + message=f"Total size exceeds {MAX_TOTAL_SIZE // (1024*1024)}MB limit" + ) + + # 6. 保存分片文件 + session_dir = get_session_dir(session_id) + chunk_path = session_dir / f"chunk_{chunk_index:04d}.webm" + + with open(chunk_path, 'wb') as f: + f.write(chunk_data) + + # 7. 更新metadata + update_session_chunks(session_id, chunk_index) + + # 8. 获取已接收分片总数 + metadata = load_session_metadata(session_id) + total_received = len(metadata['received_chunks']) + + return create_api_response( + code="200", + message="分片上传成功", + data={ + "session_id": session_id, + "chunk_index": chunk_index, + "received": True, + "total_received": total_received + } + ) + + except Exception as e: + print(f"Error uploading chunk: {e}") + return create_api_response( + code="500", + message=f"分片上传失败: {str(e)}", + data={ + "session_id": session_id, + "chunk_index": chunk_index, + "should_retry": True + } + ) + + +@router.post("/audio/stream/complete") +async def complete_upload( + request: CompleteUploadRequest, + background_tasks: BackgroundTasks, + current_user: dict = Depends(get_current_user) +): + """ + 完成上传并合并分片 + + 验证分片完整性,合并所有分片,保存最终音频文件,可选启动转录任务和自动总结 + """ + try: + # 1. 验证session_id + try: + validate_session_id(request.session_id) + except ValueError: + return create_api_response( + code="400", + message="Invalid session_id format" + ) + + # 2. 加载session metadata + try: + metadata = load_session_metadata(request.session_id) + except FileNotFoundError: + return create_api_response( + code="404", + message="Session not found" + ) + + # 3. 验证会话所有权 + if metadata['user_id'] != current_user['user_id']: + return create_api_response( + code="403", + message="Permission denied" + ) + + # 4. 验证会议ID一致性 + if metadata['meeting_id'] != request.meeting_id: + return create_api_response( + code="400", + message="Meeting ID mismatch" + ) + + # 5. 合并音频分片 + try: + file_path = merge_audio_chunks( + session_id=request.session_id, + meeting_id=request.meeting_id, + total_chunks=request.total_chunks, + mime_type=request.mime_type + ) + except ValueError as e: + # 分片不完整 + return create_api_response( + code="500", + message=f"音频合并失败:{str(e)}", + data={ + "should_retry": True + } + ) + + # 6. 获取文件信息 + full_path = BASE_DIR / file_path.lstrip('/') + file_size = full_path.stat().st_size + file_name = full_path.name + + # 7. 调用 audio_service 处理文件(数据库更新、启动转录和总结) + result = handle_audio_upload( + file_path=file_path, + file_name=file_name, + file_size=file_size, + meeting_id=request.meeting_id, + current_user=current_user, + auto_summarize=request.auto_summarize, + background_tasks=background_tasks, + prompt_id=request.prompt_id # 传递提示词模版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, + data={ + "meeting_id": request.meeting_id, + "file_path": file_path, + "file_size": file_size, + "duration": None, # 可以通过ffprobe获取,但不是必需的 + "task_id": transcription_task_id, + "task_status": "pending" if transcription_task_id else None, + "auto_summarize": request.auto_summarize + } + ) + + except Exception as e: + print(f"Error completing upload: {e}") + return create_api_response( + code="500", + message=f"完成上传失败: {str(e)}" + ) + + +@router.delete("/audio/stream/cancel") +async def cancel_upload( + request: CancelUploadRequest, + current_user: dict = Depends(get_current_user) +): + """ + 取消上传会话 + + 清理会话临时文件和目录 + """ + try: + # 1. 验证session_id + try: + validate_session_id(request.session_id) + except ValueError: + return create_api_response( + code="400", + message="Invalid session_id format" + ) + + # 2. 加载session metadata(验证所有权) + try: + metadata = load_session_metadata(request.session_id) + + # 验证会话所有权 + if metadata['user_id'] != current_user['user_id']: + return create_api_response( + code="403", + message="Permission denied" + ) + except FileNotFoundError: + # 会话不存在,视为已清理 + return create_api_response( + code="200", + message="上传会话已取消", + data={ + "session_id": request.session_id, + "cleaned": True + } + ) + + # 3. 清理会话文件 + cleanup_session(request.session_id) + + return create_api_response( + code="200", + message="上传会话已取消", + data={ + "session_id": request.session_id, + "cleaned": True + } + ) + + except Exception as e: + print(f"Error canceling upload: {e}") + return create_api_response( + code="500", + message=f"取消上传失败: {str(e)}" + ) diff --git a/app/api/endpoints/client_downloads.py b/app/api/endpoints/client_downloads.py index 0f7bcb7..1aaca1e 100644 --- a/app/api/endpoints/client_downloads.py +++ b/app/api/endpoints/client_downloads.py @@ -12,7 +12,7 @@ from typing import Optional router = APIRouter() -@router.get("/downloads", response_model=dict) +@router.get("/clients", response_model=dict) async def get_client_downloads( platform_type: Optional[str] = None, platform_name: Optional[str] = None, @@ -81,7 +81,7 @@ async def get_client_downloads( ) -@router.get("/downloads/latest", response_model=dict) +@router.get("/clients/latest", response_model=dict) async def get_latest_clients(): """ 获取所有平台的最新版本客户端(公开接口,用于首页下载) @@ -102,19 +102,23 @@ async def get_latest_clients(): # 按平台类型分组 mobile_clients = [] desktop_clients = [] + terminal_clients = [] for client in clients: if client['platform_type'] == 'mobile': mobile_clients.append(client) - else: + elif client['platform_type'] == 'desktop': desktop_clients.append(client) + elif client['platform_type'] == 'terminal': + terminal_clients.append(client) return create_api_response( code="200", message="获取成功", data={ "mobile": mobile_clients, - "desktop": desktop_clients + "desktop": desktop_clients, + "terminal": terminal_clients } ) @@ -125,10 +129,17 @@ async def get_latest_clients(): ) -@router.get("/downloads/{platform_name}/latest", response_model=dict) -async def get_latest_version_by_platform(platform_name: str): +@router.get("/clients/latest/by-platform", response_model=dict) +async def get_latest_version_by_platform_type_and_name( + platform_type: str, + platform_name: str +): """ - 获取指定平台的最新版本(公开接口,用于客户端版本检查) + 通过平台类型和平台名称获取最新版本(公开接口,用于客户端版本检查) + + 参数: + platform_type: 平台类型 (mobile, desktop, terminal) + platform_name: 具体平台 (ios, android, windows, mac_intel, mac_m, linux, mcu) """ try: with get_db_connection() as conn: @@ -136,17 +147,20 @@ async def get_latest_version_by_platform(platform_name: str): query = """ SELECT * FROM client_downloads - WHERE platform_name = %s AND is_active = TRUE AND is_latest = TRUE + WHERE platform_type = %s + AND platform_name = %s + AND is_active = TRUE + AND is_latest = TRUE LIMIT 1 """ - cursor.execute(query, (platform_name,)) + cursor.execute(query, (platform_type, platform_name)) client = cursor.fetchone() cursor.close() if not client: return create_api_response( code="404", - message=f"未找到平台 {platform_name} 的客户端" + message=f"未找到平台类型 {platform_type} 下的 {platform_name} 客户端" ) return create_api_response( @@ -162,7 +176,7 @@ async def get_latest_version_by_platform(platform_name: str): ) -@router.get("/downloads/{id}", response_model=dict) +@router.get("/clients/{id}", response_model=dict) async def get_client_download_by_id(id: int): """ 获取指定ID的客户端详情(公开接口) @@ -195,7 +209,7 @@ async def get_client_download_by_id(id: int): ) -@router.post("/downloads", response_model=dict) +@router.post("/clients", response_model=dict) async def create_client_download( request: CreateClientDownloadRequest, current_user: dict = Depends(get_current_admin_user) @@ -255,7 +269,7 @@ async def create_client_download( ) -@router.put("/downloads/{id}", response_model=dict) +@router.put("/clients/{id}", response_model=dict) async def update_client_download( id: int, request: UpdateClientDownloadRequest, @@ -353,7 +367,7 @@ async def update_client_download( ) -@router.delete("/downloads/{id}", response_model=dict) +@router.delete("/clients/{id}", response_model=dict) async def delete_client_download( id: int, current_user: dict = Depends(get_current_admin_user) diff --git a/app/api/endpoints/knowledge_base.py b/app/api/endpoints/knowledge_base.py index 93d129d..a198997 100644 --- a/app/api/endpoints/knowledge_base.py +++ b/app/api/endpoints/knowledge_base.py @@ -131,6 +131,7 @@ def create_knowledge_base( kb_id=kb_id, user_prompt=request.user_prompt, source_meeting_ids=request.source_meeting_ids, + prompt_id=request.prompt_id, # 传递 prompt_id 参数 cursor=cursor ) diff --git a/app/api/endpoints/meetings.py b/app/api/endpoints/meetings.py index 03d5821..68f2e43 100644 --- a/app/api/endpoints/meetings.py +++ b/app/api/endpoints/meetings.py @@ -7,6 +7,7 @@ import app.core.config as config_module from app.services.llm_service import LLMService 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.core.auth import get_current_user from app.core.response import create_api_response from typing import List, Optional @@ -25,194 +26,8 @@ transcription_service = AsyncTranscriptionService() class GenerateSummaryRequest(BaseModel): user_prompt: Optional[str] = "" + prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版 -def _handle_audio_upload( - audio_file: UploadFile, - meeting_id: int, - force_replace_bool: bool, - current_user: dict -): - """ - 音频上传的公共处理逻辑 - - Args: - audio_file: 上传的音频文件 - meeting_id: 会议ID - force_replace_bool: 是否强制替换 - current_user: 当前用户 - - Returns: - dict: { - "success": bool, # 是否成功 - "needs_confirmation": bool, # 是否需要用户确认 - "response": dict, # 如果需要返回,这里是响应数据 - "file_info": dict, # 文件信息 (成功时) - "transcription_task_id": str, # 转录任务ID (成功时) - "replaced_existing": bool, # 是否替换了现有文件 (成功时) - "has_transcription": bool # 原来是否有转录记录 (成功时) - } - """ - # 1. 文件类型验证 - file_extension = os.path.splitext(audio_file.filename)[1].lower() - if file_extension not in ALLOWED_EXTENSIONS: - return { - "success": False, - "response": create_api_response( - code="400", - message=f"不支持的文件类型。支持的类型: {', '.join(ALLOWED_EXTENSIONS)}" - ) - } - - # 2. 文件大小验证 - max_file_size = getattr(config_module, 'MAX_FILE_SIZE', 100 * 1024 * 1024) - if audio_file.size > max_file_size: - return { - "success": False, - "response": create_api_response( - code="400", - message=f"文件大小超过 {max_file_size // (1024 * 1024)}MB 限制" - ) - } - - # 3. 权限和已有文件检查 - try: - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 检查会议是否存在及权限 - cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) - meeting = cursor.fetchone() - if not meeting: - return { - "success": False, - "response": create_api_response(code="404", message="会议不存在") - } - if meeting['user_id'] != current_user['user_id']: - return { - "success": False, - "response": create_api_response(code="403", message="无权限操作此会议") - } - - # 检查已有音频文件 - cursor.execute( - "SELECT file_name, file_path, upload_time FROM audio_files WHERE meeting_id = %s", - (meeting_id,) - ) - existing_info = cursor.fetchone() - - # 检查是否有转录记录 - has_transcription = False - if existing_info: - cursor.execute( - "SELECT COUNT(*) as segment_count FROM transcript_segments WHERE meeting_id = %s", - (meeting_id,) - ) - has_transcription = cursor.fetchone()['segment_count'] > 0 - - cursor.close() - except Exception as e: - return { - "success": False, - "response": create_api_response(code="500", message=f"检查已有文件失败: {str(e)}") - } - - # 4. 如果已有转录记录且未确认替换,返回提示 - if existing_info and has_transcription and not force_replace_bool: - return { - "success": False, - "needs_confirmation": True, - "response": create_api_response( - code="300", - message="该会议已有音频文件和转录记录,重新上传将删除现有的转录内容和会议总结", - data={ - "requires_confirmation": True, - "existing_file": { - "file_name": existing_info['file_name'], - "upload_time": existing_info['upload_time'].isoformat() if existing_info['upload_time'] else None - } - } - ) - } - - # 5. 保存音频文件 - meeting_dir = AUDIO_DIR / str(meeting_id) - meeting_dir.mkdir(exist_ok=True) - unique_filename = f"{uuid.uuid4()}{file_extension}" - absolute_path = meeting_dir / unique_filename - relative_path = absolute_path.relative_to(BASE_DIR) - - try: - with open(absolute_path, "wb") as buffer: - shutil.copyfileobj(audio_file.file, buffer) - except Exception as e: - return { - "success": False, - "response": create_api_response(code="500", message=f"保存文件失败: {str(e)}") - } - - transcription_task_id = None - replaced_existing = existing_info is not None - - try: - # 6. 更新数据库记录 - with get_db_connection() as connection: - cursor = connection.cursor(dictionary=True) - - # 删除旧的音频文件 - if replaced_existing and force_replace_bool: - if existing_info and existing_info['file_path']: - old_file_path = BASE_DIR / existing_info['file_path'].lstrip('/') - if old_file_path.exists(): - try: - os.remove(old_file_path) - print(f"Deleted old audio file: {old_file_path}") - except Exception as e: - print(f"Warning: Failed to delete old file {old_file_path}: {e}") - - # 更新或插入音频文件记录 - if replaced_existing: - cursor.execute( - 'UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s', - (audio_file.filename, '/' + str(relative_path), audio_file.size, meeting_id) - ) - else: - cursor.execute( - 'INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) VALUES (%s, %s, %s, %s, NOW())', - (meeting_id, audio_file.filename, '/' + str(relative_path), audio_file.size) - ) - - connection.commit() - cursor.close() - - # 7. 启动转录任务 - try: - transcription_task_id = transcription_service.start_transcription(meeting_id, '/' + str(relative_path)) - print(f"Transcription task {transcription_task_id} started for meeting {meeting_id}") - except Exception as e: - print(f"Failed to start transcription: {e}") - raise - - except Exception as e: - # 出错时清理已上传的文件 - if os.path.exists(absolute_path): - os.remove(absolute_path) - return { - "success": False, - "response": create_api_response(code="500", message=f"处理失败: {str(e)}") - } - - # 8. 返回成功结果 - return { - "success": True, - "file_info": { - "file_name": audio_file.filename, - "file_path": '/' + str(relative_path), - "file_size": audio_file.size - }, - "transcription_task_id": transcription_task_id, - "replaced_existing": replaced_existing, - "has_transcription": has_transcription - } def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: """ @@ -559,8 +374,8 @@ def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_curre async def upload_audio( audio_file: UploadFile = File(...), meeting_id: int = Form(...), - force_replace: str = Form("false"), auto_summarize: str = Form("true"), + prompt_id: Optional[int] = Form(None), # 可选的提示词模版ID background_tasks: BackgroundTasks = None, current_user: dict = Depends(get_current_user) ): @@ -572,41 +387,84 @@ async def upload_audio( Args: audio_file: 音频文件 meeting_id: 会议ID - force_replace: 是否强制替换("true"/"false") auto_summarize: 是否自动生成总结("true"/"false",默认"true") + prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版) background_tasks: FastAPI后台任务 current_user: 当前登录用户 Returns: - HTTP 300: 需要用户确认(已有转录记录) HTTP 200: 处理成功,返回任务ID HTTP 400/403/404/500: 各种错误情况 """ - force_replace_bool = force_replace.lower() in ("true", "1", "yes") auto_summarize_bool = auto_summarize.lower() in ("true", "1", "yes") - # 调用公共处理方法 - result = _handle_audio_upload(audio_file, meeting_id, force_replace_bool, current_user) + # 打印接收到的 prompt_id + print(f"[Upload Audio] Meeting ID: {meeting_id}, Received prompt_id: {prompt_id}, Type: {type(prompt_id)}, Auto-summarize: {auto_summarize_bool}") - # 如果不成功,直接返回响应 + # 1. 文件类型验证 + file_extension = os.path.splitext(audio_file.filename)[1].lower() + if file_extension not in ALLOWED_EXTENSIONS: + return create_api_response( + code="400", + message=f"不支持的文件类型。支持的类型: {', '.join(ALLOWED_EXTENSIONS)}" + ) + + # 2. 文件大小验证 + max_file_size = getattr(config_module, 'MAX_FILE_SIZE', 100 * 1024 * 1024) + if audio_file.size > max_file_size: + return create_api_response( + code="400", + message=f"文件大小超过 {max_file_size // (1024 * 1024)}MB 限制" + ) + + # 3. 保存音频文件到磁盘 + meeting_dir = AUDIO_DIR / str(meeting_id) + meeting_dir.mkdir(exist_ok=True) + unique_filename = f"{uuid.uuid4()}{file_extension}" + absolute_path = meeting_dir / unique_filename + relative_path = absolute_path.relative_to(BASE_DIR) + + try: + with open(absolute_path, "wb") as buffer: + shutil.copyfileobj(audio_file.file, buffer) + except Exception as e: + return create_api_response(code="500", message=f"保存文件失败: {str(e)}") + + file_path = '/' + str(relative_path) + file_name = audio_file.filename + file_size = audio_file.size + + # 4. 调用 audio_service 处理文件(权限检查、数据库更新、启动转录) + result = handle_audio_upload( + file_path=file_path, + file_name=file_name, + file_size=file_size, + meeting_id=meeting_id, + current_user=current_user, + auto_summarize=auto_summarize_bool, + background_tasks=background_tasks, + prompt_id=prompt_id # 传递 prompt_id 参数 + ) + + # 如果不成功,删除已保存的文件并返回错误 if not result["success"]: + if absolute_path.exists(): + try: + os.remove(absolute_path) + print(f"Deleted file due to processing error: {absolute_path}") + except Exception as e: + print(f"Warning: Failed to delete file {absolute_path}: {e}") return result["response"] - # 成功:根据auto_summarize参数决定是否添加监控任务 + # 5. 返回成功响应 transcription_task_id = result["transcription_task_id"] - if auto_summarize_bool and transcription_task_id: - background_tasks.add_task( - async_meeting_service.monitor_and_auto_summarize, - meeting_id, - transcription_task_id - ) - print(f"[upload-audio] Auto-summarize enabled, monitor task added for meeting {meeting_id}") - message_suffix = ",正在进行转录和总结" - else: - print(f"[upload-audio] Auto-summarize disabled for meeting {meeting_id}") - message_suffix = "" + message_suffix = "" + if transcription_task_id: + if auto_summarize_bool: + message_suffix = ",正在进行转录和总结" + else: + message_suffix = ",正在进行转录" - # 返回成功响应 return create_api_response( code="200", message="Audio file uploaded successfully" + @@ -888,7 +746,8 @@ def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequ cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): return create_api_response(code="404", message="Meeting not found") - task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt) + # 传递 prompt_id 参数给服务层 + task_id = async_meeting_service.start_summary_generation(meeting_id, request.user_prompt, request.prompt_id) background_tasks.add_task(async_meeting_service._process_task, task_id) return create_api_response(code="200", message="Summary generation task has been accepted.", data={ "task_id": task_id, "status": "pending", "meeting_id": meeting_id @@ -1025,12 +884,14 @@ def get_meeting_preview_data(meeting_id: int): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # 检查会议是否存在 + # 检查会议是否存在,并获取模版信息 query = ''' - SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, - m.user_id as creator_id, u.caption as creator_username + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.updated_at, m.prompt_id, + m.user_id as creator_id, u.caption as creator_username, + p.name as prompt_name FROM meetings m JOIN users u ON m.user_id = u.user_id + LEFT JOIN prompts p ON m.prompt_id = p.id WHERE m.meeting_id = %s ''' cursor.execute(query, (meeting_id,)) @@ -1056,6 +917,8 @@ def get_meeting_preview_data(meeting_id: int): "meeting_time": meeting['meeting_time'], "summary": meeting['summary'], "creator_username": meeting['creator_username'], + "prompt_id": meeting['prompt_id'], + "prompt_name": meeting['prompt_name'], "attendees": attendees, "attendees_count": len(attendees) } diff --git a/app/api/endpoints/prompts.py b/app/api/endpoints/prompts.py index a6a093b..0244d6f 100644 --- a/app/api/endpoints/prompts.py +++ b/app/api/endpoints/prompts.py @@ -11,11 +11,14 @@ router = APIRouter() # Pydantic Models class PromptIn(BaseModel): name: str - tags: Optional[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 PromptListResponse(BaseModel): @@ -28,44 +31,105 @@ def create_prompt(prompt: PromptIn, current_user: dict = Depends(get_current_use with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) try: + # 如果设置为默认,需要先取消同类型其他提示词的默认状态 + if prompt.is_default: + cursor.execute( + "UPDATE prompts SET is_default = FALSE WHERE task_type = %s", + (prompt.task_type,) + ) + cursor.execute( - "INSERT INTO prompts (name, tags, content, creator_id) VALUES (%s, %s, %s, %s)", - (prompt.name, prompt.tags, prompt.content, current_user["user_id"]) + """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"]) ) 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": new_id, **prompt.dict()} + ) except Exception as e: - if "UNIQUE constraint failed" in str(e) or "Duplicate entry" in str(e): + if "Duplicate entry" in str(e): return create_api_response(code="400", message="提示词名称已存在") return create_api_response(code="500", message=f"创建提示词失败: {e}") -@router.get("/prompts") -def get_prompts(page: int = 1, size: int = 12, current_user: dict = Depends(get_current_user)): - """Get a paginated list of prompts filtered by current user.""" +@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 COUNT(*) as total FROM prompts WHERE creator_id = %s", - (current_user["user_id"],) + """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) +): + """Get a paginated list of prompts filtered by current user and optionally by task_type.""" + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 构建 WHERE 条件 + where_conditions = ["creator_id = %s"] + params = [current_user["user_id"]] + + if task_type: + where_conditions.append("task_type = %s") + params.append(task_type) + + where_clause = " AND ".join(where_conditions) + + # 获取总数 + cursor.execute( + f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}", + tuple(params) ) total = cursor.fetchone()['total'] + # 获取分页数据 offset = (page - 1) * size cursor.execute( - "SELECT id, name, tags, content, created_at FROM prompts WHERE creator_id = %s ORDER BY created_at DESC LIMIT %s OFFSET %s", - (current_user["user_id"], size, offset) + 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]) ) prompts = cursor.fetchall() - return create_api_response(code="200", message="获取提示词列表成功", data={"prompts": prompts, "total": total}) + return create_api_response( + code="200", + message="获取提示词列表成功", + data={"prompts": prompts, "total": total} + ) @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.""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute("SELECT id, name, tags, content, created_at FROM prompts WHERE id = %s", (prompt_id,)) + cursor.execute( + """SELECT id, name, task_type, content, is_default, is_active, creator_id, created_at + FROM prompts WHERE id = %s""", + (prompt_id,) + ) prompt = cursor.fetchone() if not prompt: return create_api_response(code="404", message="提示词不存在") @@ -74,19 +138,50 @@ def get_prompt(prompt_id: int, current_user: dict = Depends(get_current_user)): @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}") + with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) try: - cursor.execute( - "UPDATE prompts SET name = %s, tags = %s, content = %s WHERE id = %s", - (prompt.name, prompt.tags, prompt.content, prompt_id) - ) - if cursor.rowcount == 0: + # 先检查记录是否存在 + 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}") + cursor.execute( + "UPDATE prompts SET is_default = FALSE WHERE task_type = %s AND id != %s", + (prompt.task_type, prompt_id) + ) + print(f"[UPDATE PROMPT] Cleared {cursor.rowcount} other default prompts") + + 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) + ) + rows_affected = cursor.rowcount + print(f"[UPDATE PROMPT] UPDATE affected {rows_affected} rows (0 means no changes needed)") + + # 注意:rowcount=0 不代表记录不存在,可能是所有字段值都相同 + # 我们已经在上面确认了记录存在,所以这里直接提交即可 connection.commit() + print(f"[UPDATE PROMPT] Success! Committed changes") return create_api_response(code="200", message="提示词更新成功") except Exception as e: - if "UNIQUE constraint failed" in str(e) or "Duplicate entry" in str(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}") diff --git a/app/api/endpoints/users.py b/app/api/endpoints/users.py index d08a7f2..ff89524 100644 --- a/app/api/endpoints/users.py +++ b/app/api/endpoints/users.py @@ -147,23 +147,42 @@ def reset_password(user_id: int, current_user: dict = Depends(get_current_user)) return create_api_response(code="200", message=f"用户 {user_id} 的密码已重置") @router.get("/users") -def get_all_users(page: int = 1, size: int = 10, role_id: Optional[int] = None, current_user: dict = Depends(get_current_user)): +def get_all_users( + page: int = 1, + size: int = 10, + role_id: Optional[int] = None, + search: Optional[str] = None, + current_user: dict = Depends(get_current_user) +): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - count_query = "SELECT COUNT(*) as total FROM users" - params = [] + + # 构建WHERE条件 + where_conditions = [] + count_params = [] + if role_id is not None: - count_query += " WHERE role_id = %s" - params.append(role_id) - - cursor.execute(count_query, tuple(params)) + where_conditions.append("role_id = %s") + count_params.append(role_id) + + if search: + search_pattern = f"%{search}%" + where_conditions.append("(username LIKE %s OR caption LIKE %s)") + count_params.extend([search_pattern, search_pattern]) + + # 统计查询 + count_query = "SELECT COUNT(*) as total FROM users" + if where_conditions: + count_query += " WHERE " + " AND ".join(where_conditions) + + cursor.execute(count_query, tuple(count_params)) total = cursor.fetchone()['total'] - + offset = (page - 1) * size - + + # 主查询 query = ''' - SELECT + SELECT u.user_id, u.username, u.caption, u.email, u.created_at, u.role_id, r.role_name, (SELECT COUNT(*) FROM meetings WHERE user_id = u.user_id) as meetings_created, @@ -171,24 +190,24 @@ def get_all_users(page: int = 1, size: int = 10, role_id: Optional[int] = None, FROM users u LEFT JOIN roles r ON u.role_id = r.role_id ''' - + query_params = [] - if role_id is not None: - query += " WHERE u.role_id = %s" - query_params.append(role_id) - + if where_conditions: + query += " WHERE " + " AND ".join(where_conditions) + query_params.extend(count_params) + query += ''' ORDER BY u.user_id ASC LIMIT %s OFFSET %s ''' - + query_params.extend([size, offset]) - + cursor.execute(query, tuple(query_params)) users = cursor.fetchall() - + user_list = [UserInfo(**user) for user in users] - + response_data = UserListResponse(users=user_list, total=total) return create_api_response(code="200", message="获取用户列表成功", data=response_data.dict()) diff --git a/app/core/config.py b/app/core/config.py index bd0949d..e906840 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,6 +6,7 @@ from pathlib import Path BASE_DIR = Path(__file__).parent.parent.parent UPLOAD_DIR = BASE_DIR / "uploads" AUDIO_DIR = UPLOAD_DIR / "audio" +TEMP_UPLOAD_DIR = UPLOAD_DIR / "temp_audio" MARKDOWN_DIR = UPLOAD_DIR / "markdown" VOICEPRINT_DIR = UPLOAD_DIR / "voiceprint" diff --git a/main.py b/app/main.py similarity index 73% rename from main.py rename to app/main.py index 2e00e33..1d45b98 100644 --- a/main.py +++ b/app/main.py @@ -1,11 +1,21 @@ +import sys +import os +from pathlib import Path + +# 添加项目根目录到 Python 路径 +# 无论从哪里运行,都能正确找到 app 模块 +current_file = Path(__file__).resolve() +project_root = current_file.parent.parent # backend/ +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 app.api.endpoints import auth, users, meetings, tags, admin, tasks, prompts, knowledge_base, client_downloads, voiceprint +from app.api.endpoints import auth, users, meetings, tags, admin, admin_dashboard, tasks, prompts, knowledge_base, client_downloads, voiceprint, audio from app.core.config import UPLOAD_DIR, API_CONFIG from app.api.endpoints.admin import load_system_config -import os app = FastAPI( title="iMeeting API", @@ -35,11 +45,13 @@ 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/clients", tags=["ClientDownloads"]) +app.include_router(client_downloads.router, prefix="/api", tags=["ClientDownloads"]) app.include_router(voiceprint.router, prefix="/api", tags=["Voiceprint"]) +app.include_router(audio.router, prefix="/api", tags=["Audio"]) @app.get("/") def read_root(): @@ -57,10 +69,10 @@ def health_check(): if __name__ == "__main__": # 简单的uvicorn配置,避免参数冲突 uvicorn.run( - "main:app", - host=API_CONFIG['host'], + "app.main:app", + host=API_CONFIG['host'], port=API_CONFIG['port'], limit_max_requests=1000, timeout_keep_alive=30, reload=True, - ) \ No newline at end of file + ) diff --git a/app/models/models.py b/app/models/models.py index bfd9b86..98d6c35 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -152,6 +152,7 @@ class CreateKnowledgeBaseRequest(BaseModel): user_prompt: Optional[str] = None source_meeting_ids: Optional[str] = None tags: Optional[str] = None + prompt_id: Optional[int] = None # 提示词模版ID,如果不指定则使用默认模版 class UpdateKnowledgeBaseRequest(BaseModel): title: str @@ -227,3 +228,30 @@ class VoiceprintTemplate(BaseModel): duration_seconds: int sample_rate: int channels: int + +# 菜单权限相关模型 +class MenuInfo(BaseModel): + menu_id: int + menu_code: str + menu_name: str + menu_icon: Optional[str] = None + menu_url: Optional[str] = None + menu_type: str # 'action', 'link', 'divider' + parent_id: Optional[int] = None + sort_order: int + is_active: bool + description: Optional[str] = None + created_at: datetime.datetime + updated_at: datetime.datetime + +class MenuListResponse(BaseModel): + menus: List[MenuInfo] + total: int + +class RolePermissionInfo(BaseModel): + role_id: int + role_name: str + menu_ids: List[int] + +class UpdateRolePermissionsRequest(BaseModel): + menu_ids: List[int] diff --git a/app/services/async_knowledge_base_service.py b/app/services/async_knowledge_base_service.py index 4f19838..8a743fd 100644 --- a/app/services/async_knowledge_base_service.py +++ b/app/services/async_knowledge_base_service.py @@ -20,7 +20,7 @@ class AsyncKnowledgeBaseService: self.redis_client = redis.Redis(**REDIS_CONFIG) self.llm_service = LLMService() - def start_generation(self, user_id: int, kb_id: int, user_prompt: Optional[str], source_meeting_ids: Optional[str], cursor=None) -> str: + def start_generation(self, user_id: int, kb_id: int, user_prompt: Optional[str], source_meeting_ids: Optional[str], prompt_id: Optional[int] = None, cursor=None) -> str: """ 创建异步知识库生成任务 @@ -29,6 +29,7 @@ class AsyncKnowledgeBaseService: kb_id: 知识库ID user_prompt: 用户提示词 source_meeting_ids: 源会议ID列表 + prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版) cursor: 数据库游标(可选) Returns: @@ -39,13 +40,13 @@ class AsyncKnowledgeBaseService: # If a cursor is passed, use it directly to avoid creating a new transaction if cursor: query = """ - INSERT INTO knowledge_base_tasks (task_id, user_id, kb_id, user_prompt, created_at) - VALUES (%s, %s, %s, %s, NOW()) + INSERT INTO knowledge_base_tasks (task_id, user_id, kb_id, user_prompt, prompt_id, created_at) + VALUES (%s, %s, %s, %s, %s, NOW()) """ - cursor.execute(query, (task_id, user_id, kb_id, user_prompt)) + 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) + self._save_task_to_db(task_id, user_id, kb_id, user_prompt, prompt_id) current_time = datetime.now().isoformat() task_data = { @@ -53,6 +54,7 @@ class AsyncKnowledgeBaseService: 'user_id': str(user_id), 'kb_id': str(kb_id), 'user_prompt': user_prompt if user_prompt else "", + 'prompt_id': str(prompt_id) if prompt_id else '', 'status': 'pending', 'progress': '0', 'created_at': current_time, @@ -61,7 +63,7 @@ class AsyncKnowledgeBaseService: self.redis_client.hset(f"kb_task:{task_id}", mapping=task_data) self.redis_client.expire(f"kb_task:{task_id}", 86400) - print(f"Knowledge base generation task created: {task_id} for kb_id: {kb_id}") + print(f"Knowledge base generation task created: {task_id} for kb_id: {kb_id}, prompt_id: {prompt_id}") return task_id def _process_task(self, task_id: str): @@ -78,6 +80,8 @@ class AsyncKnowledgeBaseService: kb_id = int(task_data['kb_id']) 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 # 1. 更新状态为processing self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...") @@ -88,7 +92,7 @@ class AsyncKnowledgeBaseService: # 3. 构建提示词 self._update_task_status_in_redis(task_id, 'processing', 30, message="准备AI提示词...") - full_prompt = self._build_prompt(source_text, user_prompt) + full_prompt = self._build_prompt(source_text, user_prompt, prompt_id) # 4. 调用LLM API self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在生成知识库...") @@ -98,7 +102,7 @@ class AsyncKnowledgeBaseService: # 5. 保存结果到数据库 self._update_task_status_in_redis(task_id, 'processing', 95, message="保存结果...") - self._save_result_to_db(kb_id, generated_content) + self._save_result_to_db(kb_id, generated_content, prompt_id) # 6. 任务完成 self._update_task_in_db(task_id, 'completed', 100) @@ -156,7 +160,7 @@ class AsyncKnowledgeBaseService: print(f"获取会议总结错误: {e}") return "" - def _build_prompt(self, source_text: str, user_prompt: str) -> str: + def _build_prompt(self, source_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str: """ 构建完整的提示词 使用数据库中配置的KNOWLEDGE_TASK提示词模板 @@ -164,12 +168,13 @@ class AsyncKnowledgeBaseService: Args: source_text: 源会议总结文本 user_prompt: 用户自定义提示词 + prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版) Returns: str: 完整的提示词 """ - # 从数据库获取知识库任务的提示词模板 - system_prompt = self.llm_service.get_task_prompt('KNOWLEDGE_TASK') + # 从数据库获取知识库任务的提示词模板(支持指定prompt_id) + system_prompt = self.llm_service.get_task_prompt('KNOWLEDGE_TASK', prompt_id=prompt_id) prompt = f"{system_prompt}\n\n" @@ -180,13 +185,14 @@ class AsyncKnowledgeBaseService: return prompt - def _save_result_to_db(self, kb_id: int, content: str) -> Optional[int]: + def _save_result_to_db(self, kb_id: int, content: str, prompt_id: Optional[int] = None) -> Optional[int]: """ 保存生成结果到数据库 Args: kb_id: 知识库ID content: 生成的内容 + prompt_id: 提示词模版ID Returns: Optional[int]: 知识库ID,失败返回None @@ -194,11 +200,11 @@ class AsyncKnowledgeBaseService: try: with get_db_connection() as connection: cursor = connection.cursor() - query = "UPDATE knowledge_bases SET content = %s, updated_at = NOW() WHERE kb_id = %s" - cursor.execute(query, (content, kb_id)) + query = "UPDATE knowledge_bases SET content = %s, prompt_id = %s, updated_at = NOW() WHERE kb_id = %s" + cursor.execute(query, (content, prompt_id, kb_id)) connection.commit() - print(f"成功保存知识库内容,kb_id: {kb_id}") + print(f"成功保存知识库内容,kb_id: {kb_id}, prompt_id: {prompt_id}") return kb_id except Exception as e: @@ -243,13 +249,31 @@ class AsyncKnowledgeBaseService: except Exception as e: print(f"Error updating task status in Redis: {e}") - def _save_task_to_db(self, task_id: str, user_id: int, kb_id: int, user_prompt: str): - """保存任务到数据库""" + def _save_task_to_db(self, task_id: str, user_id: int, kb_id: int, user_prompt: str, prompt_id: Optional[int] = None): + """保存任务到数据库 + + Args: + task_id: 任务ID + user_id: 用户ID + kb_id: 知识库ID + user_prompt: 用户提示词 + prompt_id: 提示词模版ID(可选),如果为None则使用默认模版 + """ try: with get_db_connection() as connection: cursor = connection.cursor() - insert_query = "INSERT INTO knowledge_base_tasks (task_id, user_id, kb_id, user_prompt, status, progress, created_at) VALUES (%s, %s, %s, %s, 'pending', 0, NOW())" - cursor.execute(insert_query, (task_id, user_id, kb_id, user_prompt)) + + # 如果没有指定 prompt_id,获取默认的知识库总结模版ID + if prompt_id is None: + cursor.execute( + "SELECT id FROM prompts WHERE task_type = 'KNOWLEDGE_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1" + ) + default_prompt = cursor.fetchone() + if default_prompt: + prompt_id = default_prompt[0] + + insert_query = "INSERT INTO knowledge_base_tasks (task_id, user_id, kb_id, user_prompt, prompt_id, status, progress, created_at) VALUES (%s, %s, %s, %s, %s, 'pending', 0, NOW())" + cursor.execute(insert_query, (task_id, user_id, kb_id, user_prompt, prompt_id)) connection.commit() except Exception as e: print(f"Error saving task to database: {e}") diff --git a/app/services/async_meeting_service.py b/app/services/async_meeting_service.py index 50358e9..808d840 100644 --- a/app/services/async_meeting_service.py +++ b/app/services/async_meeting_service.py @@ -23,22 +23,24 @@ 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 = "") -> str: + def start_summary_generation(self, meeting_id: int, user_prompt: str = "", prompt_id: Optional[int] = None) -> str: """ 创建异步总结任务,任务的执行将由外部(如API层的BackgroundTasks)触发。 Args: meeting_id: 会议ID user_prompt: 用户额外提示词 + prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版 Returns: str: 任务ID """ + try: task_id = str(uuid.uuid4()) # 在数据库中创建任务记录 - self._save_task_to_db(task_id, meeting_id, user_prompt) + self._save_task_to_db(task_id, meeting_id, user_prompt, prompt_id) # 将任务详情存入Redis,用于快速查询状态 current_time = datetime.now().isoformat() @@ -46,6 +48,7 @@ class AsyncMeetingService: 'task_id': task_id, 'meeting_id': str(meeting_id), 'user_prompt': user_prompt, + 'prompt_id': str(prompt_id) if prompt_id else '', 'status': 'pending', 'progress': '0', 'created_at': current_time, @@ -54,7 +57,6 @@ class AsyncMeetingService: self.redis_client.hset(f"llm_task:{task_id}", mapping=task_data) self.redis_client.expire(f"llm_task:{task_id}", 86400) - print(f"Meeting summary task created: {task_id} for meeting: {meeting_id}") return task_id except Exception as e: @@ -75,6 +77,8 @@ class AsyncMeetingService: meeting_id = int(task_data['meeting_id']) 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 # 1. 更新状态为processing self._update_task_status_in_redis(task_id, 'processing', 10, message="任务已开始...") @@ -87,7 +91,7 @@ class AsyncMeetingService: # 3. 构建提示词 self._update_task_status_in_redis(task_id, 'processing', 40, message="准备AI提示词...") - full_prompt = self._build_prompt(transcript_text, user_prompt) + full_prompt = self._build_prompt(transcript_text, user_prompt, prompt_id) # 4. 调用LLM API self._update_task_status_in_redis(task_id, 'processing', 50, message="AI正在分析会议内容...") @@ -97,7 +101,7 @@ class AsyncMeetingService: # 5. 保存结果到主表 self._update_task_status_in_redis(task_id, 'processing', 95, message="保存总结结果...") - self._save_summary_to_db(meeting_id, summary_content, user_prompt) + 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) @@ -111,7 +115,7 @@ class AsyncMeetingService: 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) - def monitor_and_auto_summarize(self, meeting_id: int, transcription_task_id: str): + def monitor_and_auto_summarize(self, meeting_id: int, transcription_task_id: str, prompt_id: Optional[int] = None): """ 监控转录任务,完成后自动生成总结 此方法设计为由BackgroundTasks调用,在后台运行 @@ -119,13 +123,14 @@ class AsyncMeetingService: Args: meeting_id: 会议ID transcription_task_id: 转录任务ID + prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版) 流程: 1. 循环轮询转录任务状态 2. 转录成功后自动启动总结任务 3. 转录失败或超时则停止轮询并记录日志 """ - print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}") + print(f"[Monitor] Started monitoring transcription task {transcription_task_id} for meeting {meeting_id}, prompt_id: {prompt_id}") # 获取配置参数 poll_interval = TRANSCRIPTION_POLL_CONFIG['poll_interval'] @@ -156,7 +161,7 @@ class AsyncMeetingService: # 启动总结任务 try: - summary_task_id = self.start_summary_generation(meeting_id, user_prompt="") + 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}") # 在后台执行总结任务 @@ -231,13 +236,18 @@ class AsyncMeetingService: print(f"获取会议转录内容错误: {e}") return "" - def _build_prompt(self, transcript_text: str, user_prompt: str) -> str: + def _build_prompt(self, transcript_text: str, user_prompt: str, prompt_id: Optional[int] = None) -> str: """ 构建完整的提示词 使用数据库中配置的MEETING_TASK提示词模板 + + Args: + transcript_text: 会议转录文本 + user_prompt: 用户额外提示词 + prompt_id: 可选的提示词模版ID,如果不指定则使用默认模版 """ - # 从数据库获取会议任务的提示词模板 - system_prompt = self.llm_service.get_task_prompt('MEETING_TASK') + # 从数据库获取会议任务的提示词模板(支持指定prompt_id) + system_prompt = self.llm_service.get_task_prompt('MEETING_TASK', prompt_id=prompt_id) prompt = f"{system_prompt}\n\n" @@ -248,22 +258,22 @@ class AsyncMeetingService: return prompt - def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str) -> Optional[int]: - """保存总结到数据库 - 更新meetings表的summary、user_prompt和updated_at字段""" + def _save_summary_to_db(self, meeting_id: int, summary_content: str, user_prompt: str, prompt_id: Optional[int] = None) -> Optional[int]: + """保存总结到数据库 - 更新meetings表的summary、user_prompt、prompt_id和updated_at字段""" try: with get_db_connection() as connection: cursor = connection.cursor() - # 更新meetings表的summary、user_prompt和updated_at字段 + # 更新meetings表的summary、user_prompt、prompt_id和updated_at字段 update_query = """ UPDATE meetings - SET summary = %s, user_prompt = %s, updated_at = NOW() + SET summary = %s, user_prompt = %s, prompt_id = %s, updated_at = NOW() WHERE meeting_id = %s """ - cursor.execute(update_query, (summary_content, user_prompt, meeting_id)) + cursor.execute(update_query, (summary_content, user_prompt, prompt_id, meeting_id)) connection.commit() - print(f"成功保存会议总结到meetings表,meeting_id: {meeting_id}") + print(f"成功保存会议总结到meetings表,meeting_id: {meeting_id}, prompt_id: {prompt_id}") return meeting_id except Exception as e: @@ -326,14 +336,39 @@ class AsyncMeetingService: 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): - """保存任务到数据库""" + def _save_task_to_db(self, task_id: str, meeting_id: int, user_prompt: str, prompt_id: Optional[int] = None): + """保存任务到数据库 + + Args: + task_id: 任务ID + meeting_id: 会议ID + user_prompt: 用户额外提示词 + prompt_id: 可选的提示词模版ID,如果为None则使用默认模版 + """ try: with get_db_connection() as connection: cursor = connection.cursor() - insert_query = "INSERT INTO llm_tasks (task_id, meeting_id, user_prompt, status, progress, created_at) VALUES (%s, %s, %s, 'pending', 0, NOW())" - cursor.execute(insert_query, (task_id, meeting_id, user_prompt)) + + # 如果没有指定 prompt_id,获取默认的会议总结模版ID + if prompt_id is None: + print(f"[Meeting Service] prompt_id is None, fetching default template for MEETING_TASK") + cursor.execute( + "SELECT id FROM prompts WHERE task_type = 'MEETING_TASK' AND is_default = TRUE AND is_active = TRUE LIMIT 1" + ) + default_prompt = cursor.fetchone() + if default_prompt: + prompt_id = default_prompt[0] + print(f"[Meeting Service] Found default template ID: {prompt_id}") + else: + print(f"[Meeting Service] WARNING: No default template found for MEETING_TASK!") + else: + print(f"[Meeting Service] Using provided prompt_id: {prompt_id}") + + print(f"[Meeting Service] Inserting task into llm_tasks - task_id: {task_id}, meeting_id: {meeting_id}, prompt_id: {prompt_id}") + 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)) connection.commit() + print(f"[Meeting Service] Task saved successfully to database") except Exception as e: print(f"Error saving task to database: {e}") raise diff --git a/app/services/audio_service.py b/app/services/audio_service.py new file mode 100644 index 0000000..5142699 --- /dev/null +++ b/app/services/audio_service.py @@ -0,0 +1,172 @@ +""" +音频处理服务 + +处理已保存的完整音频文件:数据库更新、转录、自动总结 +""" +from fastapi import BackgroundTasks +from app.core.database import get_db_connection +from app.core.response import create_api_response +from app.core.config import BASE_DIR +from app.services.async_transcription_service import AsyncTranscriptionService +from app.services.async_meeting_service import async_meeting_service +from pathlib import Path +import os + + +transcription_service = AsyncTranscriptionService() + + +def handle_audio_upload( + file_path: str, + file_name: str, + file_size: int, + meeting_id: int, + current_user: dict, + auto_summarize: bool = True, + background_tasks: BackgroundTasks = None, + prompt_id: int = None +) -> dict: + """ + 处理已保存的完整音频文件 + + 职责: + 1. 权限检查 + 2. 检查已有文件和转录记录 + 3. 更新数据库(audio_files 表) + 4. 启动转录任务 + 5. 可选启动自动总结监控 + + Args: + file_path: 已保存的文件路径(相对于 BASE_DIR 的路径,如 /uploads/audio/123/xxx.webm) + file_name: 原始文件名 + file_size: 文件大小(字节) + meeting_id: 会议ID + current_user: 当前用户信息 + auto_summarize: 是否自动生成总结(默认True) + background_tasks: FastAPI 后台任务对象 + prompt_id: 提示词模版ID(可选,如果不指定则使用默认模版) + + Returns: + dict: { + "success": bool, # 是否成功 + "response": dict, # 如果需要返回,这里是响应数据 + "file_info": dict, # 文件信息 (成功时) + "transcription_task_id": str, # 转录任务ID (成功时) + "replaced_existing": bool, # 是否替换了现有文件 (成功时) + "has_transcription": bool # 原来是否有转录记录 (成功时) + } + """ + print(f"[Audio Service] handle_audio_upload called - Meeting ID: {meeting_id}, Auto-summarize: {auto_summarize}, Received prompt_id: {prompt_id}, Type: {type(prompt_id)}") + + # 1. 权限和已有文件检查 + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 检查会议是否存在及权限 + cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) + meeting = cursor.fetchone() + if not meeting: + return { + "success": False, + "response": create_api_response(code="404", message="会议不存在") + } + if meeting['user_id'] != current_user['user_id']: + return { + "success": False, + "response": create_api_response(code="403", message="无权限操作此会议") + } + + # 检查已有音频文件 + cursor.execute( + "SELECT file_name, file_path, upload_time FROM audio_files WHERE meeting_id = %s", + (meeting_id,) + ) + existing_info = cursor.fetchone() + + # 检查是否有转录记录 + has_transcription = False + if existing_info: + cursor.execute( + "SELECT COUNT(*) as segment_count FROM transcript_segments WHERE meeting_id = %s", + (meeting_id,) + ) + has_transcription = cursor.fetchone()['segment_count'] > 0 + + cursor.close() + except Exception as e: + return { + "success": False, + "response": create_api_response(code="500", message=f"检查已有文件失败: {str(e)}") + } + + # 2. 删除旧的音频文件(如果存在) + replaced_existing = existing_info is not None + if replaced_existing and existing_info['file_path']: + old_file_path = BASE_DIR / existing_info['file_path'].lstrip('/') + if old_file_path.exists(): + try: + os.remove(old_file_path) + print(f"Deleted old audio file: {old_file_path}") + except Exception as e: + print(f"Warning: Failed to delete old file {old_file_path}: {e}") + + transcription_task_id = None + + try: + # 3. 更新数据库记录 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + if replaced_existing: + cursor.execute( + 'UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s', + (file_name, file_path, file_size, meeting_id) + ) + else: + cursor.execute( + 'INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) VALUES (%s, %s, %s, %s, NOW())', + (meeting_id, file_name, file_path, file_size) + ) + + connection.commit() + cursor.close() + + # 4. 启动转录任务 + try: + transcription_task_id = transcription_service.start_transcription(meeting_id, file_path) + 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, + meeting_id, + transcription_task_id, + prompt_id # 传递 prompt_id 给自动总结监控任务 + ) + print(f"[audio_service] Auto-summarize enabled, monitor task added for meeting {meeting_id}, prompt_id: {prompt_id}") + + except Exception as e: + print(f"Failed to start transcription: {e}") + raise + + except Exception as e: + # 出错时的处理(文件已保存,不删除) + return { + "success": False, + "response": create_api_response(code="500", message=f"处理失败: {str(e)}") + } + + # 6. 返回成功结果 + return { + "success": True, + "file_info": { + "file_name": file_name, + "file_path": file_path, + "file_size": file_size + }, + "transcription_task_id": transcription_task_id, + "replaced_existing": replaced_existing, + "has_transcription": has_transcription + } diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 6018862..a08ee0d 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -38,39 +38,54 @@ class LLMService: """动态获取top_p""" return config_module.LLM_CONFIG["top_p"] - def get_task_prompt(self, task_name: str, cursor=None) -> str: + def get_task_prompt(self, task_type: str, cursor=None, prompt_id: Optional[int] = None) -> str: """ 统一的提示词获取方法 Args: - task_name: 任务名称,如 'MEETING_TASK', 'KNOWLEDGE_TASK' 等 + task_type: 任务类型,如 'MEETING_TASK', 'KNOWLEDGE_TASK' 等 cursor: 数据库游标,如果传入则使用,否则创建新连接 + prompt_id: 可选的提示词ID,如果指定则使用该提示词,否则使用默认提示词 Returns: str: 提示词内容,如果未找到返回默认提示词 """ - query = """ - SELECT p.content - FROM prompt_config pc - JOIN prompts p ON pc.prompt_id = p.id - WHERE pc.task_name = %s - """ + # 如果指定了 prompt_id,直接获取该提示词 + if prompt_id: + query = """ + SELECT content + FROM prompts + WHERE id = %s AND task_type = %s AND is_active = TRUE + LIMIT 1 + """ + params = (prompt_id, task_type) + else: + # 否则获取默认提示词 + query = """ + SELECT content + FROM prompts + WHERE task_type = %s + AND is_default = TRUE + AND is_active = TRUE + LIMIT 1 + """ + params = (task_type,) if cursor: - cursor.execute(query, (task_name,)) + cursor.execute(query, params) result = cursor.fetchone() if result: return result['content'] if isinstance(result, dict) else result[0] else: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - cursor.execute(query, (task_name,)) + cursor.execute(query, params) result = cursor.fetchone() if result: return result['content'] # 返回默认提示词 - return self._get_default_prompt(task_name) + return self._get_default_prompt(task_type) def _get_default_prompt(self, task_name: str) -> str: """获取默认提示词""" diff --git a/sql/README_terminal_update.md b/sql/README_terminal_update.md new file mode 100644 index 0000000..6e934c8 --- /dev/null +++ b/sql/README_terminal_update.md @@ -0,0 +1,201 @@ +# 客户端管理 - 专用终端类型添加说明 + +## 概述 + +本次更新在客户端管理系统中添加了"专用终端"(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` - 本说明文档 diff --git a/sql/add_dedicated_terminal.sql b/sql/add_dedicated_terminal.sql new file mode 100644 index 0000000..e0140ad --- /dev/null +++ b/sql/add_dedicated_terminal.sql @@ -0,0 +1,59 @@ +-- 添加专用终端类型支持 +-- 修改 platform_type 枚举,添加 'terminal' 类型 + +ALTER TABLE client_downloads +MODIFY COLUMN platform_type ENUM('mobile', 'desktop', 'terminal') NOT NULL +COMMENT '平台类型:mobile-移动端, desktop-桌面端, terminal-专用终端'; + +-- 插入专用终端示例数据 + +-- Android 专用终端 +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 +( + 'terminal', + 'android', + '1.0.0', + 1000, + 'https://download.imeeting.com/terminals/android/iMeeting-1.0.0-Terminal.apk', + 25165824, -- 24MB + '专用终端初始版本 +- 支持专用硬件集成 +- 优化的录音功能 +- 低功耗模式 +- 自动上传同步', + TRUE, + TRUE, + 'Android 5.0', + 1 +), + +-- 单片机(MCU)专用终端 +( + 'terminal', + 'mcu', + '1.0.0', + 1000, + 'https://download.imeeting.com/terminals/mcu/iMeeting-1.0.0-MCU.bin', + 2097152, -- 2MB + '单片机固件初始版本 +- 嵌入式录音系统 +- 低功耗设计 +- 支持WiFi/4G上传 +- 硬件级音频处理', + TRUE, + TRUE, + 'ESP32 / STM32', + 1 +); diff --git a/sql/add_menu_permissions_system.sql b/sql/add_menu_permissions_system.sql new file mode 100644 index 0000000..7fbad14 --- /dev/null +++ b/sql/add_menu_permissions_system.sql @@ -0,0 +1,99 @@ +-- =================================================================== +-- 菜单权限系统数据库迁移脚本 +-- 创建日期: 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; diff --git a/sql/add_prompt_id_to_llm_tasks.sql b/sql/add_prompt_id_to_llm_tasks.sql new file mode 100644 index 0000000..7a57b99 --- /dev/null +++ b/sql/add_prompt_id_to_llm_tasks.sql @@ -0,0 +1,11 @@ +-- 为 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 表中的记录可能被删除,我们希望保留历史任务记录 diff --git a/sql/imeeting.sql b/sql/imeeting.sql index 0b767fa..b5ae92e 100644 --- a/sql/imeeting.sql +++ b/sql/imeeting.sql @@ -534,10 +534,12 @@ CREATE TABLE `knowledge_bases` ( `is_shared` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为共享知识库', `source_meeting_ids` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '内容来源的会议ID列表 (逗号分隔)', `tags` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '逗号分隔的标签', + `prompt_id` int(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`kb_id`), KEY `idx_creator_id` (`creator_id`), + KEY `idx_prompt_id` (`prompt_id`), CONSTRAINT `knowledge_bases_ibfk_1` FOREIGN KEY (`creator_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE ) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库条目表'; @@ -686,9 +688,11 @@ CREATE TABLE `meetings` ( `meeting_time` timestamp NULL DEFAULT NULL, `user_prompt` text COLLATE utf8mb4_unicode_ci, `summary` text CHARACTER SET utf8mb4, + `prompt_id` int(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`meeting_id`) + PRIMARY KEY (`meeting_id`), + KEY `idx_prompt_id` (`prompt_id`) ) ENGINE=InnoDB AUTO_INCREMENT=372 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- ---------------------------- diff --git a/sql/migrate_prompts_table.sql b/sql/migrate_prompts_table.sql new file mode 100644 index 0000000..92cdbde --- /dev/null +++ b/sql/migrate_prompts_table.sql @@ -0,0 +1,67 @@ +-- 提示词表改造迁移脚本 +-- 将 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; diff --git a/sql/migrations/add_prompt_id_to_main_tables.sql b/sql/migrations/add_prompt_id_to_main_tables.sql new file mode 100644 index 0000000..e0ba82f --- /dev/null +++ b/sql/migrations/add_prompt_id_to_main_tables.sql @@ -0,0 +1,33 @@ +-- ============================================ +-- 添加 prompt_id 字段到主表 +-- 创建时间: 2025-01-11 +-- 说明: 在 meetings 和 knowledge_bases 表中添加 prompt_id 字段 +-- 用于记录会议/知识库使用的提示词模版 +-- ============================================ + +-- 1. 为 meetings 表添加 prompt_id 字段 +ALTER TABLE meetings +ADD COLUMN prompt_id INT(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版' +AFTER summary; + +-- 为 meetings 表添加索引 +ALTER TABLE meetings +ADD INDEX idx_prompt_id (prompt_id); + +-- 2. 为 knowledge_bases 表添加 prompt_id 字段 +ALTER TABLE knowledge_bases +ADD COLUMN prompt_id INT(11) DEFAULT 0 COMMENT '使用的提示词模版ID,0表示未使用或使用默认模版' +AFTER tags; + +-- 为 knowledge_bases 表添加索引 +ALTER TABLE knowledge_bases +ADD INDEX idx_prompt_id (prompt_id); + +-- ============================================ +-- 验证修改 +-- ============================================ +-- 查看 meetings 表结构 +-- DESCRIBE meetings; + +-- 查看 knowledge_bases 表结构 +-- DESCRIBE knowledge_bases; diff --git a/sql/transcript_tasks_setup.sql b/sql/transcript_tasks_setup.sql new file mode 100644 index 0000000..135c751 --- /dev/null +++ b/sql/transcript_tasks_setup.sql @@ -0,0 +1,53 @@ +-- 为现有数据库添加转录任务支持的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) +) \ No newline at end of file diff --git a/test/test_kb_prompt_id_feature.py b/test/test_kb_prompt_id_feature.py new file mode 100644 index 0000000..7625ffc --- /dev/null +++ b/test/test_kb_prompt_id_feature.py @@ -0,0 +1,166 @@ +""" +测试知识库提示词模版选择功能 +""" +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) diff --git a/test/test_menu_permissions.py b/test/test_menu_permissions.py new file mode 100644 index 0000000..e3ca959 --- /dev/null +++ b/test/test_menu_permissions.py @@ -0,0 +1,89 @@ +#!/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() diff --git a/test/test_prompt_id_feature.py b/test/test_prompt_id_feature.py new file mode 100644 index 0000000..b8ce390 --- /dev/null +++ b/test/test_prompt_id_feature.py @@ -0,0 +1,176 @@ +""" +测试提示词模版选择功能 +""" +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)