From 2f36474f4d87b2d573d1cf0074816f0cb5c9a86e Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 31 Oct 2025 14:54:54 +0800 Subject: [PATCH] 1.0.3 --- .DS_Store | Bin 8196 -> 10244 bytes app.zip | Bin 58116 -> 0 bytes app/api/endpoints/knowledge_base.py | 44 +++--- app/api/endpoints/meetings.py | 24 +-- app/api/endpoints/voiceprint.py | 131 +++++++++++++++++ app/core/config.py | 13 ++ app/models/models.py | 25 +++- app/services/voiceprint_service.py | 218 ++++++++++++++++++++++++++++ config/system_config.json | 4 +- main.py | 3 +- migrations/add_meetings_fields.sql | 17 --- test_voiceprint_api.py | 141 ++++++++++++++++++ 12 files changed, 569 insertions(+), 51 deletions(-) delete mode 100644 app.zip create mode 100644 app/api/endpoints/voiceprint.py create mode 100644 app/services/voiceprint_service.py delete mode 100644 migrations/add_meetings_fields.sql create mode 100644 test_voiceprint_api.py diff --git a/.DS_Store b/.DS_Store index ef921b4e802a19433cdeeefc8a9a744350bb2ed3..2b4cb54eade5ec0375c19bc43c38b89d468316f7 100644 GIT binary patch literal 10244 zcmeHM&2Jk;6n~SL*iPDX)5dKhC|E>7ND0CDP*8+WT_+JlP!h#aNN9_@_D-B7>m7G@ zoivFe30y013@km;X=y6C@>cC5t0H7&|RRgd7_y^5P0cbIz$03CT zjfJM5p(%?>3>MW6EHp=dG15N{X=qMNMTVa!GK(q{7L^_>uhfYZL#p2z0gXU60^Kcqe4-#C7G)>87=E9;C72!a-z|HtBiuUV(q@n>;#dR+ z2<_8r;5={^ko9T16RRF+6JMBX&=bIR2Od1wN|YP$AYjYk;ndeYtF*7C3$i2!Lp7H(S;+c2>(YS{6un$V6N?C+oY!5%q0oSB;&&ri*%gFQJ_m>tj0 z7N!#?Tb^9Bb5HhX=uAv2j&-8TuI)ytjQ!Z2@$ZJwPG;HufeRN0FBwCZuM7{2XofoMog>8&Q*6^~0#8#Z`3m(iPlDe!5u z_->ndHDnAL4H?w0!)-32Y|Rxe&t^x9E0H8aV>qa{X&p)OfIg=$=xh3cex}FtCp}?l zHo{(JV{C@K#cr~9*b@7YRoH!IhSojpr2ax@9QHB36YHBu>nWTsnMig!lZHA)@pEya z7vDN3LZ?1iu8BrdhVOg)heY6{uOSu|qTj~MFLST_HhAMFnL%Uy*1z^Qw@-bi>k-^C zkZu|Qjetf#BcKt`2t1Pr^oFy_4}dP7|NsAIa_)Lk8Uc+!hyY71lov|i+l5XN7 delta 136 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 List[Tag]: +def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: + """ + 处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签 + """ if not tag_string: return [] tag_names = [name.strip() for name in tag_string.split(',') if name.strip()] if not tag_names: return [] - - placeholders = ','.join(['%s'] * len(tag_names)) - select_query = f"SELECT id, name, color FROM tags WHERE name IN ({placeholders})" - cursor.execute(select_query, tuple(tag_names)) + + # 如果提供了 creator_id,则创建不存在的标签 + if creator_id: + insert_ignore_query = "INSERT IGNORE INTO tags (name, creator_id) VALUES (%s, %s)" + cursor.executemany(insert_ignore_query, [(name, creator_id) for name in tag_names]) + + # 查询所有标签信息 + format_strings = ', '.join(['%s'] * len(tag_names)) + cursor.execute(f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", tuple(tag_names)) tags_data = cursor.fetchall() - - existing_tags = {tag['name']: tag for tag in tags_data} - new_tags = [name for name in tag_names if name not in existing_tags] - - if new_tags: - insert_query = "INSERT INTO tags (name) VALUES (%s)" - cursor.executemany(insert_query, [(name,) for name in new_tags]) - - # Re-fetch all tags to get their IDs and default colors - cursor.execute(select_query, tuple(tag_names)) - tags_data = cursor.fetchall() - return [Tag(**tag) for tag in tags_data] @@ -83,7 +79,8 @@ def get_knowledge_bases( kb_list = [] for kb_data in kbs_data: - kb_data['tags'] = _process_tags(cursor, kb_data.get('tags')) + # 列表页不需要处理 tags,直接使用字符串 + # kb_data['tags'] 保持原样(逗号分隔的标签名称字符串) # Count source meetings - filter empty strings if kb_data.get('source_meeting_ids'): meeting_ids = [mid.strip() for mid in kb_data['source_meeting_ids'].split(',') if mid.strip()] @@ -122,7 +119,7 @@ def create_knowledge_base( request.is_shared, request.source_meeting_ids, request.user_prompt, - request.tags, + request.tags, # 创建时 tags 应该为 None 或空字符串 now, now )) @@ -136,7 +133,7 @@ def create_knowledge_base( source_meeting_ids=request.source_meeting_ids, cursor=cursor ) - + connection.commit() # Add the background task to process the knowledge base generation @@ -171,7 +168,8 @@ def get_knowledge_base_detail( if not kb_data['is_shared'] and kb_data['creator_id'] != current_user['user_id']: raise HTTPException(status_code=403, detail="Access denied") - # Process tags + # Process tags - 获取标签的完整信息(包括颜色) + # 详情页不需要创建新标签,所以不传 creator_id kb_data['tags'] = _process_tags(cursor, kb_data.get('tags')) # Get source meetings details @@ -220,6 +218,10 @@ def update_knowledge_base( if kb['creator_id'] != current_user['user_id']: raise HTTPException(status_code=403, detail="Only the creator can update this knowledge base") + # 使用 _process_tags 处理标签(会自动创建新标签) + if request.tags: + _process_tags(cursor, request.tags, current_user['user_id']) + # Update the knowledge base now = datetime.datetime.utcnow() update_query = """ diff --git a/app/api/endpoints/meetings.py b/app/api/endpoints/meetings.py index 9ef76b2..ff632bf 100644 --- a/app/api/endpoints/meetings.py +++ b/app/api/endpoints/meetings.py @@ -23,14 +23,22 @@ transcription_service = AsyncTranscriptionService() class GenerateSummaryRequest(BaseModel): user_prompt: Optional[str] = "" -def _process_tags(cursor, tag_string: Optional[str]) -> List[Tag]: +def _process_tags(cursor, tag_string: Optional[str], creator_id: Optional[int] = None) -> List[Tag]: + """ + 处理标签:查询已存在的标签,如果提供了 creator_id 则创建不存在的标签 + """ if not tag_string: return [] tag_names = [name.strip() for name in tag_string.split(',') if name.strip()] if not tag_names: return [] - insert_ignore_query = "INSERT IGNORE INTO tags (name) VALUES (%s)" - cursor.executemany(insert_ignore_query, [(name,) for name in tag_names]) + + # 如果提供了 creator_id,则创建不存在的标签 + if creator_id: + insert_ignore_query = "INSERT IGNORE INTO tags (name, creator_id) VALUES (%s, %s)" + cursor.executemany(insert_ignore_query, [(name, creator_id) for name in tag_names]) + + # 查询所有标签信息 format_strings = ', '.join(['%s'] * len(tag_names)) cursor.execute(f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", tuple(tag_names)) tags_data = cursor.fetchall() @@ -125,10 +133,9 @@ def get_meeting_transcript(meeting_id: int, current_user: dict = Depends(get_cur def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) + # 使用 _process_tags 来处理标签创建 if meeting_request.tags: - tag_names = [name.strip() for name in meeting_request.tags.split(',') if name.strip()] - if tag_names: - cursor.executemany("INSERT IGNORE INTO tags (name) VALUES (%s)", [(name,) for name in tag_names]) + _process_tags(cursor, meeting_request.tags, current_user['user_id']) meeting_query = 'INSERT INTO meetings (user_id, title, meeting_time, summary, tags, created_at) VALUES (%s, %s, %s, %s, %s, %s)' cursor.execute(meeting_query, (meeting_request.user_id, meeting_request.title, meeting_request.meeting_time, None, meeting_request.tags, datetime.now().isoformat())) meeting_id = cursor.lastrowid @@ -147,10 +154,9 @@ def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, curre return create_api_response(code="404", message="Meeting not found") if meeting['user_id'] != current_user['user_id']: return create_api_response(code="403", message="Permission denied") + # 使用 _process_tags 来处理标签创建 if meeting_request.tags: - tag_names = [name.strip() for name in meeting_request.tags.split(',') if name.strip()] - if tag_names: - cursor.executemany("INSERT IGNORE INTO tags (name) VALUES (%s)", [(name,) for name in tag_names]) + _process_tags(cursor, meeting_request.tags, current_user['user_id']) update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s WHERE meeting_id = %s' cursor.execute(update_query, (meeting_request.title, meeting_request.meeting_time, meeting_request.summary, meeting_request.tags, meeting_id)) cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) diff --git a/app/api/endpoints/voiceprint.py b/app/api/endpoints/voiceprint.py new file mode 100644 index 0000000..b7e333b --- /dev/null +++ b/app/api/endpoints/voiceprint.py @@ -0,0 +1,131 @@ +""" +声纹采集API接口 +""" +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException +from typing import Optional +from pathlib import Path +import datetime + +from app.models.models import VoiceprintStatus, VoiceprintTemplate +from app.core.auth import get_current_user +from app.core.response import create_api_response +from app.services.voiceprint_service import voiceprint_service +import app.core.config as config_module + +router = APIRouter() + + +@router.get("/voiceprint/template", response_model=None) +def get_voiceprint_template(current_user: dict = Depends(get_current_user)): + """ + 获取声纹采集朗读模板配置 + + 权限:需要登录 + """ + try: + template_data = VoiceprintTemplate(**config_module.VOICEPRINT_CONFIG) + return create_api_response(code="200", message="获取朗读模板成功", data=template_data.dict()) + except Exception as e: + return create_api_response(code="500", message=f"获取朗读模板失败: {str(e)}") + + +@router.get("/voiceprint/{user_id}", response_model=None) +def get_voiceprint_status(user_id: int, current_user: dict = Depends(get_current_user)): + """ + 获取用户声纹采集状态 + + 权限:用户只能查询自己的声纹状态,管理员可查询所有 + """ + # 权限检查:只能查询自己的声纹,或者是管理员 + if current_user['user_id'] != user_id and current_user['role_id'] != 1: + return create_api_response(code="403", message="无权限查询其他用户的声纹状态") + + try: + status_data = voiceprint_service.get_user_voiceprint_status(user_id) + return create_api_response(code="200", message="获取声纹状态成功", data=status_data) + except Exception as e: + return create_api_response(code="500", message=f"获取声纹状态失败: {str(e)}") + + +@router.post("/voiceprint/{user_id}", response_model=None) +async def upload_voiceprint( + user_id: int, + audio_file: UploadFile = File(...), + current_user: dict = Depends(get_current_user) +): + """ + 上传声纹音频文件(同步处理) + + 权限:用户只能上传自己的声纹,管理员可操作所有 + """ + # 权限检查 + if current_user['user_id'] != user_id and current_user['role_id'] != 1: + return create_api_response(code="403", message="无权限上传其他用户的声纹") + + # 检查文件格式 + file_ext = Path(audio_file.filename).suffix.lower() + if file_ext not in config_module.ALLOWED_VOICEPRINT_EXTENSIONS: + return create_api_response( + code="400", + message=f"不支持的文件格式,仅支持: {', '.join(config_module.ALLOWED_VOICEPRINT_EXTENSIONS)}" + ) + + # 检查文件大小 + max_size = config_module.VOICEPRINT_CONFIG.get('max_file_size', 5242880) # 默认5MB + content = await audio_file.read() + file_size = len(content) + + if file_size > max_size: + return create_api_response( + code="400", + message=f"文件过大,最大允许 {max_size / 1024 / 1024:.1f}MB" + ) + + try: + # 确保用户目录存在 + user_voiceprint_dir = config_module.VOICEPRINT_DIR / str(user_id) + user_voiceprint_dir.mkdir(parents=True, exist_ok=True) + + # 生成文件名:时间戳.wav + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}.wav" + file_path = user_voiceprint_dir / filename + + # 保存文件 + with open(file_path, "wb") as f: + f.write(content) + + # 调用服务处理声纹(提取特征向量,保存到数据库) + result = voiceprint_service.save_voiceprint(user_id, str(file_path), file_size) + + return create_api_response(code="200", message="声纹采集成功", data=result) + + except Exception as e: + # 如果出错,删除已上传的文件 + if 'file_path' in locals() and Path(file_path).exists(): + Path(file_path).unlink() + + return create_api_response(code="500", message=f"声纹采集失败: {str(e)}") + + +@router.delete("/voiceprint/{user_id}", response_model=None) +def delete_voiceprint(user_id: int, current_user: dict = Depends(get_current_user)): + """ + 删除用户声纹数据,允许重新采集 + + 权限:用户只能删除自己的声纹,管理员可操作所有 + """ + # 权限检查 + if current_user['user_id'] != user_id and current_user['role_id'] != 1: + return create_api_response(code="403", message="无权限删除其他用户的声纹") + + try: + success = voiceprint_service.delete_voiceprint(user_id) + + if success: + return create_api_response(code="200", message="声纹删除成功") + else: + return create_api_response(code="404", message="未找到该用户的声纹数据") + + except Exception as e: + return create_api_response(code="500", message=f"删除声纹失败: {str(e)}") diff --git a/app/core/config.py b/app/core/config.py index 1a9f5fb..d50d6f9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,4 +1,5 @@ import os +import json from pathlib import Path # 基础路径配置 @@ -6,10 +7,12 @@ BASE_DIR = Path(__file__).parent.parent.parent UPLOAD_DIR = BASE_DIR / "uploads" AUDIO_DIR = UPLOAD_DIR / "audio" MARKDOWN_DIR = UPLOAD_DIR / "markdown" +VOICEPRINT_DIR = UPLOAD_DIR / "voiceprint" # 文件上传配置 ALLOWED_EXTENSIONS = {".mp3", ".wav", ".m4a", ".mpeg", ".mp4"} ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} +ALLOWED_VOICEPRINT_EXTENSIONS = {".wav"} MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB @@ -17,6 +20,7 @@ MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB UPLOAD_DIR.mkdir(exist_ok=True) AUDIO_DIR.mkdir(exist_ok=True) MARKDOWN_DIR.mkdir(exist_ok=True) +VOICEPRINT_DIR.mkdir(exist_ok=True) # 数据库配置 DATABASE_CONFIG = { @@ -82,3 +86,12 @@ LLM_CONFIG = { # 密码重置配置 DEFAULT_RESET_PASSWORD = os.getenv('DEFAULT_RESET_PASSWORD', '111111') + +# 加载系统配置文件 +# 默认声纹配置 +VOICEPRINT_CONFIG = { + "template_text": "我正在进行声纹采集,这段语音将用于身份识别和验证。\n\n声纹技术能够准确识别每个人独特的声音特征。", + "duration_seconds": 12, + "sample_rate": 16000, + "channels": 1 +} diff --git a/app/models/models.py b/app/models/models.py index 0df7182..bfd9b86 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -128,7 +128,7 @@ class KnowledgeBase(BaseModel): is_shared: bool source_meeting_ids: Optional[str] = None user_prompt: Optional[str] = None - tags: Optional[List[Tag]] = [] + tags: Union[Optional[str], Optional[List[Tag]]] = None # 支持字符串或Tag列表 created_at: datetime.datetime updated_at: datetime.datetime source_meeting_count: Optional[int] = 0 @@ -204,3 +204,26 @@ class UpdateClientDownloadRequest(BaseModel): class ClientDownloadListResponse(BaseModel): clients: List[ClientDownload] total: int + +# 声纹采集相关模型 +class VoiceprintInfo(BaseModel): + vp_id: int + user_id: int + file_path: str + file_size: Optional[int] = None + duration_seconds: Optional[float] = None + collected_at: datetime.datetime + updated_at: datetime.datetime + +class VoiceprintStatus(BaseModel): + has_voiceprint: bool + vp_id: Optional[int] = None + file_path: Optional[str] = None + duration_seconds: Optional[float] = None + collected_at: Optional[datetime.datetime] = None + +class VoiceprintTemplate(BaseModel): + template_text: str + duration_seconds: int + sample_rate: int + channels: int diff --git a/app/services/voiceprint_service.py b/app/services/voiceprint_service.py new file mode 100644 index 0000000..13b04f7 --- /dev/null +++ b/app/services/voiceprint_service.py @@ -0,0 +1,218 @@ +""" +声纹服务 - 处理用户声纹采集、存储和验证 +""" +import os +import json +import wave +from datetime import datetime +from typing import Optional, Dict +from pathlib import Path + +from app.core.database import get_db_connection +import app.core.config as config_module + + +class VoiceprintService: + """声纹服务类 - 同步处理声纹采集""" + + def __init__(self): + self.voiceprint_dir = config_module.VOICEPRINT_DIR + self.voiceprint_config = config_module.VOICEPRINT_CONFIG + + def get_user_voiceprint_status(self, user_id: int) -> Dict: + """ + 获取用户声纹状态 + + Args: + user_id: 用户ID + + Returns: + Dict: 声纹状态信息 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + query = """ + SELECT vp_id, user_id, file_path, file_size, duration_seconds, collected_at, updated_at + FROM user_voiceprint + WHERE user_id = %s + """ + cursor.execute(query, (user_id,)) + voiceprint = cursor.fetchone() + + if voiceprint: + return { + "has_voiceprint": True, + "vp_id": voiceprint['vp_id'], + "file_path": voiceprint['file_path'], + "duration_seconds": float(voiceprint['duration_seconds']) if voiceprint['duration_seconds'] else None, + "collected_at": voiceprint['collected_at'].isoformat() if voiceprint['collected_at'] else None + } + else: + return { + "has_voiceprint": False, + "vp_id": None, + "file_path": None, + "duration_seconds": None, + "collected_at": None + } + except Exception as e: + print(f"获取声纹状态错误: {e}") + raise e + + def save_voiceprint(self, user_id: int, audio_file_path: str, file_size: int) -> Dict: + """ + 保存声纹文件并提取特征向量 + + Args: + user_id: 用户ID + audio_file_path: 音频文件路径 + file_size: 文件大小 + + Returns: + Dict: 保存结果 + """ + try: + # 1. 获取音频时长 + duration = self._get_audio_duration(audio_file_path) + + # 2. 提取声纹向量(调用FunASR) + vector_data = self._extract_voiceprint_vector(audio_file_path) + + # 3. 保存到数据库 + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 检查用户是否已有声纹 + cursor.execute("SELECT vp_id FROM user_voiceprint WHERE user_id = %s", (user_id,)) + existing = cursor.fetchone() + + # 计算相对路径 + relative_path = str(Path(audio_file_path).relative_to(config_module.BASE_DIR)) + + if existing: + # 更新现有记录 + update_query = """ + UPDATE user_voiceprint + SET file_path = %s, file_size = %s, duration_seconds = %s, + vector_data = %s, updated_at = NOW() + WHERE user_id = %s + """ + cursor.execute(update_query, ( + relative_path, file_size, duration, + json.dumps(vector_data) if vector_data else None, + user_id + )) + vp_id = existing['vp_id'] + else: + # 插入新记录 + insert_query = """ + INSERT INTO user_voiceprint + (user_id, file_path, file_size, duration_seconds, vector_data, collected_at, updated_at) + VALUES (%s, %s, %s, %s, %s, NOW(), NOW()) + """ + cursor.execute(insert_query, ( + user_id, relative_path, file_size, duration, + json.dumps(vector_data) if vector_data else None + )) + vp_id = cursor.lastrowid + + connection.commit() + + return { + "vp_id": vp_id, + "user_id": user_id, + "file_path": relative_path, + "file_size": file_size, + "duration_seconds": duration, + "has_vector": vector_data is not None + } + + except Exception as e: + print(f"保存声纹错误: {e}") + raise e + + def delete_voiceprint(self, user_id: int) -> bool: + """ + 删除用户声纹 + + Args: + user_id: 用户ID + + Returns: + bool: 是否删除成功 + """ + try: + with get_db_connection() as connection: + cursor = connection.cursor(dictionary=True) + + # 获取文件路径 + cursor.execute("SELECT file_path FROM user_voiceprint WHERE user_id = %s", (user_id,)) + voiceprint = cursor.fetchone() + + if voiceprint: + # 构建完整文件路径 + relative_path = voiceprint['file_path'] + if relative_path.startswith('/'): + relative_path = relative_path.lstrip('/') + file_path = config_module.BASE_DIR / relative_path + + # 删除数据库记录 + cursor.execute("DELETE FROM user_voiceprint WHERE user_id = %s", (user_id,)) + connection.commit() + + # 删除文件 + if file_path.exists(): + os.remove(file_path) + + return True + else: + return False + + except Exception as e: + print(f"删除声纹错误: {e}") + raise e + + def _get_audio_duration(self, audio_file_path: str) -> float: + """ + 获取音频文件时长 + + Args: + audio_file_path: 音频文件路径 + + Returns: + float: 时长(秒) + """ + try: + with wave.open(audio_file_path, 'rb') as wav_file: + frames = wav_file.getnframes() + rate = wav_file.getframerate() + duration = frames / float(rate) + return round(duration, 2) + except Exception as e: + print(f"获取音频时长错误: {e}") + return 10.0 # 默认返回10秒 + + def _extract_voiceprint_vector(self, audio_file_path: str) -> Optional[list]: + """ + 提取声纹特征向量(调用FunASR) + + Args: + audio_file_path: 音频文件路径 + + Returns: + Optional[list]: 声纹向量(192维),失败返回None + """ + # TODO: 集成FunASR的说话人识别模型 + # 使用 speech_campplus_sv_zh-cn_16k-common 模型 + # 返回192维的embedding向量 + + print(f"[TODO] 调用FunASR提取声纹向量: {audio_file_path}") + + # 暂时返回None,等待FunASR集成 + # 集成后应该返回类似: [0.123, -0.456, 0.789, ...] + return None + + +# 创建全局实例 +voiceprint_service = VoiceprintService() diff --git a/config/system_config.json b/config/system_config.json index 9cec9a3..92bdf2a 100644 --- a/config/system_config.json +++ b/config/system_config.json @@ -1,7 +1,7 @@ { "model_name": "qwen-plus", "system_prompt": "你是一个专业的会议记录分析助手。请根据提供的会议转录内容,生成简洁明了的会议总结。\n\n总结包括五个部分(名称严格一致,生成为MD二级目录):\n1. 会议概述 - 简要说明会议的主要目的和背景(生成MD引用)\n2. 主要讨论点 - 列出会议中讨论的重要话题和内容\n3. 决策事项 - 明确记录会议中做出的决定和结论\n4. 待办事项 - 列出需要后续跟进的任务和责任人\n5. 关键信息 - 其他重要的信息点\n\n输出要求:\n- 保持客观中性,不添加个人观点\n- 使用简洁、准确的中文表达\n- 按重要性排序各项内容\n- 如果某个部分没有相关内容,可以说明\"无相关内容\"\n- 总字数控制在500字以内", - "DEFAULT_RESET_PASSWORD": "111111", - "MAX_FILE_SIZE": 209715200, + "DEFAULT_RESET_PASSWORD": "123456", + "MAX_FILE_SIZE": 208666624, "MAX_IMAGE_SIZE": 10485760 } \ No newline at end of file diff --git a/main.py b/main.py index 22836e0..2e00e33 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ 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 +from app.api.endpoints import auth, users, meetings, tags, admin, tasks, prompts, knowledge_base, client_downloads, voiceprint from app.core.config import UPLOAD_DIR, API_CONFIG from app.api.endpoints.admin import load_system_config import os @@ -39,6 +39,7 @@ 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(voiceprint.router, prefix="/api", tags=["Voiceprint"]) @app.get("/") def read_root(): diff --git a/migrations/add_meetings_fields.sql b/migrations/add_meetings_fields.sql deleted file mode 100644 index af532f7..0000000 --- a/migrations/add_meetings_fields.sql +++ /dev/null @@ -1,17 +0,0 @@ --- 为meetings表添加updated_at和user_prompt字段 --- 执行日期: 2025-10-28 - --- 添加updated_at字段 -ALTER TABLE meetings -ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -AFTER created_at; - --- 添加user_prompt字段 -ALTER TABLE meetings -ADD COLUMN user_prompt TEXT -AFTER summary; - --- 为现有记录设置updated_at为created_at的值 -UPDATE meetings -SET updated_at = created_at -WHERE updated_at IS NULL; diff --git a/test_voiceprint_api.py b/test_voiceprint_api.py new file mode 100644 index 0000000..eebfa19 --- /dev/null +++ b/test_voiceprint_api.py @@ -0,0 +1,141 @@ +""" +声纹采集API测试脚本 + +使用方法: +1. 确保后端服务正在运行 +2. 修改 USER_ID 和 TOKEN 为实际值 +3. 准备一个10秒的WAV音频文件 +4. 运行: python test_voiceprint_api.py +""" + +import requests +import json + +# 配置 +BASE_URL = "http://localhost:8000/api" +USER_ID = 1 # 修改为实际用户ID +TOKEN = "" # 登录后获取的token + +# 请求头 +headers = { + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json" +} + + +def test_get_template(): + """测试获取朗读模板""" + print("\n=== 测试1: 获取朗读模板 ===") + url = f"{BASE_URL}/voiceprint/template" + response = requests.get(url, headers=headers) + print(f"状态码: {response.status_code}") + print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}") + return response.json() + + +def test_get_status(user_id): + """测试获取声纹状态""" + print(f"\n=== 测试2: 获取用户 {user_id} 的声纹状态 ===") + url = f"{BASE_URL}/voiceprint/{user_id}" + response = requests.get(url, headers=headers) + print(f"状态码: {response.status_code}") + print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}") + return response.json() + + +def test_upload_voiceprint(user_id, audio_file_path): + """测试上传声纹""" + print(f"\n=== 测试3: 上传声纹音频 ===") + url = f"{BASE_URL}/voiceprint/{user_id}" + + # 移除Content-Type,让requests自动设置multipart/form-data + upload_headers = { + "Authorization": f"Bearer {TOKEN}" + } + + with open(audio_file_path, 'rb') as f: + files = {'audio_file': (audio_file_path.split('/')[-1], f, 'audio/wav')} + response = requests.post(url, headers=upload_headers, files=files) + + print(f"状态码: {response.status_code}") + print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}") + return response.json() + + +def test_delete_voiceprint(user_id): + """测试删除声纹""" + print(f"\n=== 测试4: 删除用户 {user_id} 的声纹 ===") + url = f"{BASE_URL}/voiceprint/{user_id}" + response = requests.delete(url, headers=headers) + print(f"状态码: {response.status_code}") + print(f"响应: {json.dumps(response.json(), ensure_ascii=False, indent=2)}") + return response.json() + + +def login(username, password): + """登录获取token""" + print("\n=== 登录获取Token ===") + url = f"{BASE_URL}/auth/login" + data = { + "username": username, + "password": password + } + response = requests.post(url, json=data) + if response.status_code == 200: + result = response.json() + if result.get('code') == '200': + token = result['data']['token'] + print(f"登录成功,Token: {token[:20]}...") + return token + else: + print(f"登录失败: {result.get('message')}") + return None + else: + print(f"请求失败,状态码: {response.status_code}") + return None + + +if __name__ == "__main__": + print("=" * 60) + print("声纹采集API测试脚本") + print("=" * 60) + + # 步骤1: 登录(如果没有token) + if not TOKEN: + print("\n请先登录获取Token...") + username = input("用户名: ") + password = input("密码: ") + TOKEN = login(username, password) + if TOKEN: + headers["Authorization"] = f"Bearer {TOKEN}" + else: + print("登录失败,退出测试") + exit(1) + + # 步骤2: 测试获取朗读模板 + test_get_template() + + # 步骤3: 测试获取声纹状态 + test_get_status(USER_ID) + + # 步骤4: 测试上传声纹(需要准备音频文件) + audio_file = input("\n请输入WAV音频文件路径 (回车跳过上传测试): ") + if audio_file.strip(): + test_upload_voiceprint(USER_ID, audio_file.strip()) + + # 上传后再次查看状态 + print("\n=== 上传后再次查看状态 ===") + test_get_status(USER_ID) + + # 步骤5: 测试删除声纹 + confirm = input("\n是否测试删除声纹? (yes/no): ") + if confirm.lower() == 'yes': + test_delete_voiceprint(USER_ID) + + # 删除后再次查看状态 + print("\n=== 删除后再次查看状态 ===") + test_get_status(USER_ID) + + print("\n" + "=" * 60) + print("测试完成") + print("=" * 60)