From bff6e3eedce1bc54c107c1b55a911f39760492ee Mon Sep 17 00:00:00 2001 From: wxg0103 <727495428@qq.com> Date: Tue, 25 Aug 2026 17:20:21 +0800 Subject: [PATCH] feat: add Tencent Tokenhub ASR sync_transcribe credential and enhance verification code handling --- apps/locales/en_US/LC_MESSAGES/django.po | 43 +++++++++- apps/locales/zh_CN/LC_MESSAGES/django.po | 46 +++++++++- apps/locales/zh_Hant/LC_MESSAGES/django.po | 47 +++++++++- .../credential/tokenhub_stt.py | 83 ++++++++++++++++++ .../impl/tencent_model_provider/model/stt.py | 73 +++++++++++++++- .../tencent_model_provider.py | 15 +++- apps/users/serializers/user.py | 86 +++++++++++++++---- 7 files changed, 368 insertions(+), 25 deletions(-) create mode 100644 apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py diff --git a/apps/locales/en_US/LC_MESSAGES/django.po b/apps/locales/en_US/LC_MESSAGES/django.po index 2da721e00c5..a6d7abfc2a0 100644 --- a/apps/locales/en_US/LC_MESSAGES/django.po +++ b/apps/locales/en_US/LC_MESSAGES/django.po @@ -8732,7 +8732,6 @@ msgstr "" msgid "generate prompt" msgstr "" - msgid "Watermark" msgstr "" @@ -9605,3 +9604,45 @@ msgstr "" msgid "Get portal historical conversation by page" msgstr "" + +msgid "Too many verification code attempts, please try again later" +msgstr "" + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:18 +msgid "Recognition language" +msgstr "" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:18 +msgid "Recognition language: zh / en, auto detected when omitted" +msgstr "" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:22 +msgid "Auto detect" +msgstr "" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:23 +msgid "Chinese" +msgstr "" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:30 +msgid "Audio encoding" +msgstr "" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:30 +msgid "pcm / wav / ogg / mp3, auto detected when omitted" +msgstr "" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:34 +msgid "Auto" +msgstr "" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:76 +msgid "Tokenhub sync_transcribe endpoint" +msgstr "" diff --git a/apps/locales/zh_CN/LC_MESSAGES/django.po b/apps/locales/zh_CN/LC_MESSAGES/django.po index db3403da64e..b6bc4e199a7 100644 --- a/apps/locales/zh_CN/LC_MESSAGES/django.po +++ b/apps/locales/zh_CN/LC_MESSAGES/django.po @@ -8837,7 +8837,6 @@ msgstr "系统资源授权" msgid "This folder contains resources that you dont have permission" msgstr "此文件夹包含您没有权限的资源" - msgid "Text to Video" msgstr "文生视频" @@ -9588,7 +9587,6 @@ msgstr "角色管理" msgid "Chat User Group" msgstr "对话用户组" - msgid "Create or update Workspace User Group" msgstr "创建或更新工作空间用户组" @@ -9747,4 +9745,46 @@ msgstr "分页获取门户历史会话" #: apps/xpack/views/system_chat_user.py:101 msgid "Batch set chat user quota" -msgstr "批量设置对话用户配额" \ No newline at end of file +msgstr "批量设置对话用户配额" + +msgid "Too many verification code attempts, please try again later" +msgstr "验证码尝试次数过多,请稍后重试" + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:18 +msgid "Recognition language" +msgstr "识别语言" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:18 +msgid "Recognition language: zh / en, auto detected when omitted" +msgstr "识别语言:zh / en,缺省时自动检测" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:22 +msgid "Auto detect" +msgstr "自动检测" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:23 +msgid "Chinese" +msgstr "中文" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:30 +msgid "Audio encoding" +msgstr "音频编码" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:30 +msgid "pcm / wav / ogg / mp3, auto detected when omitted" +msgstr "pcm / wav / ogg / mp3,缺省时自动检测" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:34 +msgid "Auto" +msgstr "自动" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:76 +msgid "Tokenhub sync_transcribe endpoint" +msgstr "Tokenhub 同步转写接口地址" \ No newline at end of file diff --git a/apps/locales/zh_Hant/LC_MESSAGES/django.po b/apps/locales/zh_Hant/LC_MESSAGES/django.po index 2d107379503..0b513faf124 100644 --- a/apps/locales/zh_Hant/LC_MESSAGES/django.po +++ b/apps/locales/zh_Hant/LC_MESSAGES/django.po @@ -8837,7 +8837,6 @@ msgstr "系統資源授權" msgid "This folder contains resources that you dont have permission" msgstr "此資料夾包含您沒有許可權的資源" - msgid "Text to Video" msgstr "文生視頻" @@ -9702,7 +9701,6 @@ msgstr "刪除對話用戶 API 密鑰" msgid "Quota Setting" msgstr "配額設置" - #: apps/xpack/serializers/chat_user.py:746 msgid "Quota mode" msgstr "配額模式" @@ -9743,4 +9741,47 @@ msgid "The token quota for the current period has been exhausted. Please contact msgstr "當前週期 Tokens 配額已用盡,請聯繫管理員。" msgid "Get portal historical conversation by page" -msgstr "分頁獲取門戶歷史會話" \ No newline at end of file +msgstr "分頁獲取門戶歷史會話" + +msgid "Too many verification code attempts, please try again later" +msgstr "驗證碼嘗試次數過多,請稍後重試" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:18 +msgid "Recognition language" +msgstr "識別語言" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:18 +msgid "Recognition language: zh / en, auto detected when omitted" +msgstr "識別語言:zh / en,缺省時自動檢測" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:22 +msgid "Auto detect" +msgstr "自動檢測" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:23 +msgid "Chinese" +msgstr "中文" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:30 +msgid "Audio encoding" +msgstr "音頻編碼" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:30 +msgid "pcm / wav / ogg / mp3, auto detected when omitted" +msgstr "pcm / wav / ogg / mp3,缺省時自動檢測" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:34 +msgid "Auto" +msgstr "自動" + + +#: apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py:76 +msgid "Tokenhub sync_transcribe endpoint" +msgstr "Tokenhub 同步轉寫接口地址" diff --git a/apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py b/apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py new file mode 100644 index 00000000000..c4051f9ee2d --- /dev/null +++ b/apps/models_provider/impl/tencent_model_provider/credential/tokenhub_stt.py @@ -0,0 +1,83 @@ +# coding=utf-8 +""" +@project: MaxKB +@desc: Tencent Tokenhub ASR sync_transcribe credential (model: wand-asr-v1 / hy-asr-3.0-preview) +""" + +from django.utils.translation import gettext_lazy as _, gettext + +from common import forms +from common.exception.app_exception import AppApiException +from common.forms import BaseForm, TooltipLabel +from common.utils.logger import maxkb_logger +from models_provider.base_model_provider import BaseModelCredential, ValidCode + + +class TencentTokenhubSTTModelParams(BaseForm): + source = forms.SingleSelect( + label=TooltipLabel(_("Recognition language"), _("Recognition language: zh / en, auto detected when omitted")), + text_field="value", + value_field="value", + option_list=[ + {"value": "", "label": _("Auto detect")}, + {"value": "zh", "label": _("Chinese")}, + {"value": "en", "label": _("English")}, + ], + required=False, + default_value="", + ) + voice_encode_format = forms.SingleSelect( + label=TooltipLabel(_("Audio encoding"), _("pcm / wav / ogg / mp3, auto detected when omitted")), + text_field="value", + value_field="value", + option_list=[ + {"value": "", "label": _("Auto")}, + {"value": "pcm", "label": "pcm"}, + {"value": "wav", "label": "wav"}, + {"value": "ogg", "label": "ogg"}, + {"value": "mp3", "label": "mp3"}, + ], + required=False, + default_value="", + ) + + +class TencentTokenhubSTTModelCredential(BaseForm, BaseModelCredential): + def is_valid(self, model_type, model_name, model_credential, model_params, provider, raise_exception=False): + model_type_list = provider.get_model_type_list() + if not any(list(filter(lambda mt: mt.get("value") == model_type, model_type_list))): + raise AppApiException( + ValidCode.valid_error.value, + gettext("{model_type} Model type is not supported").format(model_type=model_type), + ) + if "api_key" not in model_credential: + if raise_exception: + raise AppApiException(ValidCode.valid_error.value, gettext("{key} is required").format(key="api_key")) + return False + try: + model = provider.get_model(model_type, model_name, model_credential, **model_params) + model.check_auth() + except Exception as e: + maxkb_logger.error(f"Exception: {e}", exc_info=True) + if raise_exception: + raise AppApiException( + ValidCode.valid_error.value, + gettext("Verification failed, please check whether the parameters are correct: {error}").format( + error=str(e) + ), + ) + return False + return True + + def encryption_dict(self, model): + return {**model, "api_key": super().encryption(model.get("api_key", ""))} + + base_url = forms.TextInputField( + label=TooltipLabel(_("API URL"), _("Tokenhub sync_transcribe endpoint")), + required=False, + default_value="https://tokenhub.tencentmaas.com/v1/wand/asrproxy/sync_transcribe", + ) + api_key = forms.PasswordInputField(_("API Key"), required=True) + + def get_model_params_setting_form(self, model_name): + return TencentTokenhubSTTModelParams() diff --git a/apps/models_provider/impl/tencent_model_provider/model/stt.py b/apps/models_provider/impl/tencent_model_provider/model/stt.py index d8d4e592fbb..6240c1c2394 100644 --- a/apps/models_provider/impl/tencent_model_provider/model/stt.py +++ b/apps/models_provider/impl/tencent_model_provider/model/stt.py @@ -2,7 +2,9 @@ import json import os import traceback -from typing import Dict + +import requests +from typing import Dict, Optional from tencentcloud.asr.v20190614 import asr_client, models from tencentcloud.common import credential @@ -81,3 +83,72 @@ def speech_to_text(self, audio_file): except TencentCloudSDKException as err: maxkb_logger.error(f":Error: {str(err)}: {traceback.format_exc()}") raise err + + +DEFAULT_WAND_BASE_URL = "https://tokenhub.tencentmaas.com/v1/wand/asrproxy/sync_transcribe" + + +class TencentWandSpeechToText(MaxKBBaseModel, BaseSpeechToText): + api_key: str + model: str + params: dict + base_url: Optional[str] = DEFAULT_WAND_BASE_URL + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.api_key = kwargs.get("api_key") + self.model = kwargs.get("model") + self.params = kwargs.get("params") or {} + self.base_url = kwargs.get("base_url") or DEFAULT_WAND_BASE_URL + + @staticmethod + def is_cache_model(): + return False + + @staticmethod + def new_instance(model_type, model_name, model_credential: Dict[str, object], **model_kwargs): + instance_kwargs = { + "api_key": model_credential.get("api_key"), + "model": model_name, + "params": model_kwargs, + **model_kwargs, + } + base_url = model_credential.get("base_url") + if base_url: + instance_kwargs["base_url"] = base_url + return TencentWandSpeechToText(**instance_kwargs) + + def check_auth(self): + cwd = os.path.dirname(os.path.abspath(__file__)) + with open(f"{cwd}/iat_mp3_16k.mp3", "rb") as f: + self.speech_to_text(f) + + def speech_to_text(self, audio_file): + try: + payload = {"model": self.model} + # 仅使用上传音频文件的 base64 data,不提供 input_url 兜底 + audio_data = audio_file.read() + payload["data"] = base64.b64encode(audio_data).decode("utf-8") + for key in ("source", "voice_encode_format"): + if self.params.get(key): + payload[key] = self.params[key] + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + response = requests.post(self.base_url, headers=headers, json=payload, timeout=300) + response.raise_for_status() + result = response.json() + if result.get("status") != "completed": + maxkb_logger.error(f"WAND ASR task not completed: {result}") + raise Exception(f"WAND ASR task not completed: {result}") + output = result.get("output") or {} + text = output.get("text") + if not text: + sentences = output.get("sentences") or [] + text = " ".join([s.get("text", "") for s in sentences if s.get("text")]) + return text + except Exception as e: + maxkb_logger.error(f"WAND ASR Error: {str(e)}: {traceback.format_exc()}") + raise e diff --git a/apps/models_provider/impl/tencent_model_provider/tencent_model_provider.py b/apps/models_provider/impl/tencent_model_provider/tencent_model_provider.py index aede7bc6486..6d7c8fdcffb 100644 --- a/apps/models_provider/impl/tencent_model_provider/tencent_model_provider.py +++ b/apps/models_provider/impl/tencent_model_provider/tencent_model_provider.py @@ -14,11 +14,12 @@ from models_provider.impl.tencent_model_provider.credential.image import TencentVisionModelCredential from models_provider.impl.tencent_model_provider.credential.llm import TencentLLMModelCredential from models_provider.impl.tencent_model_provider.credential.stt import TencentSTTModelCredential +from models_provider.impl.tencent_model_provider.credential.tokenhub_stt import TencentTokenhubSTTModelCredential from models_provider.impl.tencent_model_provider.credential.tti import TencentTTIModelCredential from models_provider.impl.tencent_model_provider.model.embedding import TencentEmbeddingModel from models_provider.impl.tencent_model_provider.model.image import TencentVision from models_provider.impl.tencent_model_provider.model.llm import TencentModel -from models_provider.impl.tencent_model_provider.model.stt import TencentSpeechToText +from models_provider.impl.tencent_model_provider.model.stt import TencentSpeechToText, TencentWandSpeechToText from models_provider.impl.tencent_model_provider.model.tti import TencentTextToImageModel from maxkb.conf import PROJECT_DIR from django.utils.translation import gettext as _ @@ -105,6 +106,12 @@ def _initialize_model_info(): TencentSTTModelCredential, TencentSpeechToText, ), + _create_model_info( + "wand-asr-v1", _(""), ModelTypeConst.STT, TencentTokenhubSTTModelCredential, TencentWandSpeechToText + ), + _create_model_info( + "hy-asr-3.0-preview", _(""), ModelTypeConst.STT, TencentTokenhubSTTModelCredential, TencentWandSpeechToText + ), ] tencent_embedding_model_info = _create_model_info( @@ -162,6 +169,12 @@ def __init__(self): def get_model_info_manage(self): return self._model_info_manage + def get_model(self, model_type, model_name, model_credential, **model_kwargs): + # STT 模型:模型名不以 asr- 开头的一律走 Tencent Tokenhub WAND 识别 + if model_type == ModelTypeConst.STT.name and not model_name.startswith("asr-"): + return TencentWandSpeechToText.new_instance(model_type, model_name, model_credential, **model_kwargs) + return super().get_model(model_type, model_name, model_credential, **model_kwargs) + def get_model_provide_info(self): icon_path = _get_tencent_icon_path() icon_data = get_file_content(icon_path) diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index a733f462153..a57e418d1d7 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -53,6 +53,65 @@ EMAIL_CODE_TYPE_REGEX = re.compile(r"^(register|reset_password)$") +MAX_VERIFY_CODE_ATTEMPTS = 5 +VERIFY_CODE_EXPIRE_SECONDS = 10 * 60 +# 达到错误上限后的锁定冷却时长 +VERIFY_CODE_LOCKOUT_SECONDS = 10 * 60 + + +def _raise_verify_code_limit(): + raise AppApiException(500, _("Too many verification code attempts, please try again later")) + + +def check_verify_code_lockout(email: str, type_code: str): + """ + 检查验证码是否处于锁定冷却期 + """ + lock_cache_key = get_key(email + ":" + type_code + "_locked") + if cache.get(lock_cache_key, version=version): + _raise_verify_code_limit() + + +def check_verify_code_attempts(email: str, type_code: str, submitted_code: str) -> bool: + """ + 校验验证码并限制错误尝试次数,防止验证码被暴力破解(CWE-307)。 + 失败计数按邮箱累计且不随重新发送验证码清零:连续错误达到上限后,进入固定冷却期的 + 锁定,锁定期间即使验证码正确也一律拒绝,必须等待冷却期结束才能重新尝试,从而 + 避免通过反复发送验证码维持无限猜解节奏。 + 校验通过时返回 True,否则抛出校验异常。 + """ + code_cache_key = email + ":" + type_code + failed_cache_key = code_cache_key + "_failed_attempts" + lock_cache_key = code_cache_key + "_locked" + cached_code = cache.get(get_key(code_cache_key), version=version) + failed_attempts = int(cache.get(get_key(failed_cache_key), version=version) or 0) + # 已进入锁定冷却期(独立锁 key,固定 10 分钟):无论验证码是否正确都拒绝, + # 且不刷新锁定时长 + if cache.get(get_key(lock_cache_key), version=version): + cache.delete(get_key(code_cache_key), version=version) + _raise_verify_code_limit() + if cached_code is None: + raise ExceptionCodeConstants.CODE_ERROR.value.to_app_api_exception() + if cached_code != submitted_code: + failed_attempts += 1 + cache.set( + get_key(failed_cache_key), + failed_attempts, + timeout=VERIFY_CODE_LOCKOUT_SECONDS, + version=version, + ) + if failed_attempts >= MAX_VERIFY_CODE_ATTEMPTS: + # 错满 5 次:验证码立即失效,并写入独立锁 key 进入固定 10 分钟锁定 + cache.delete(get_key(code_cache_key), version=version) + cache.set(get_key(lock_cache_key), True, timeout=VERIFY_CODE_LOCKOUT_SECONDS, version=version) + _raise_verify_code_limit() + raise ExceptionCodeConstants.CODE_ERROR.value.to_app_api_exception() + # 校验通过,清除错误尝试计数与锁定 + cache.delete(get_key(failed_cache_key), version=version) + cache.delete(get_key(lock_cache_key), version=version) + return True + + class UserProfileResponse(serializers.ModelSerializer): is_edit_password = serializers.BooleanField(required=True, label=_("Is Edit Password")) permissions = serializers.ListField(required=True, label=_("permissions")) @@ -553,9 +612,9 @@ def edit(self, instance, user_id, with_valid=True): @staticmethod def _check_admin_modification(user, instance): if ( - user.role == RoleConstants.ADMIN.name - and "is_active" in instance - and instance.get("is_active") is not None + user.role == RoleConstants.ADMIN.name + and "is_active" in instance + and instance.get("is_active") is not None ): raise AppApiException(1004, _("Cannot modify administrator status")) @@ -620,7 +679,7 @@ def get_user_list(self, user_id, workspace_id, nick_name): if workspace_user_role_mapping_model: # 判断当前用户是否属于该空间,不属于直接返回空 if not workspace_user_role_mapping_model.objects.filter( - workspace_id=workspace_id, user_id=user_id + workspace_id=workspace_id, user_id=user_id ).exists(): query_set = User.objects.none() else: @@ -696,9 +755,9 @@ def update_user_role(instance, user, user_id=None): license_is_valid = DatabaseModelManage.get_model("license_is_valid") or (lambda: False) license_is_valid = license_is_valid() if license_is_valid() is not None else False if not role_setting or ( - len(role_setting) == 1 - and role_setting[0].get("role_id") == "" - and len(role_setting[0].get("workspace_ids", [])) == 0 + len(role_setting) == 1 + and role_setting[0].get("role_id") == "" + and len(role_setting[0].get("workspace_ids", [])) == 0 ): if not license_is_valid: workspace_user_role_mapping_model.objects.create( @@ -827,16 +886,12 @@ class Meta: def is_valid(self, *, raise_exception=False): super().is_valid(raise_exception=True) email = self.data.get("email") - cache_code = cache.get(get_key(email + ":reset_password"), version=version) if self.data.get("password") != self.data.get("re_password"): raise AppApiException( ExceptionCodeConstants.PASSWORD_NOT_EQ_RE_PASSWORD.value.code, ExceptionCodeConstants.PASSWORD_NOT_EQ_RE_PASSWORD.value.message, ) - if cache_code != self.data.get("code"): - raise AppApiException( - ExceptionCodeConstants.CODE_ERROR.value.code, ExceptionCodeConstants.CODE_ERROR.value.message - ) + check_verify_code_attempts(email, "reset_password", self.data.get("code")) return True def reset_password(self): @@ -950,6 +1005,7 @@ def send(self): """ email = self.data.get("email") state = self.data.get("type") + check_verify_code_lockout(email, state) code = "".join(random.choices("0123456789", k=6)) language = get_language() template_path = os.path.join(PROJECT_DIR, "apps", "common", "template", f"email_template_{language}.html") @@ -992,7 +1048,7 @@ def send(self): except Exception: cache.delete(get_key(code_cache_key_lock), version=version) raise AppApiException(500, _("Failed to send email. Please try again later.")) - cache.set(get_key(code_cache_key), code, timeout=60 * 30, version=version) + cache.set(get_key(code_cache_key), code, timeout=VERIFY_CODE_EXPIRE_SECONDS, version=version) return True @@ -1027,9 +1083,7 @@ class CheckCodeSerializer(serializers.Serializer): def is_valid(self, *, raise_exception=False): super().is_valid(raise_exception=raise_exception) - value = cache.get(get_key(self.data.get("email") + ":" + self.data.get("type")), version=version) - if value is None or value != self.data.get("code"): - raise ExceptionCodeConstants.CODE_ERROR.value.to_app_api_exception() + check_verify_code_attempts(self.data.get("email"), self.data.get("type"), self.data.get("code")) return True