From dc4f5750afc300a0b62357b62941d26f4c792ae4 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Mon, 25 Aug 2025 14:45:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E6=B7=BB=E5=8A=A02FA=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=90=8E=E6=9F=A5=E7=9C=8B=E6=B8=A0=E9=81=93=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增接口通过2FA验证后获取渠道密钥 - 统一实现2FA验证码和备用码的验证逻辑 - 记录用户查看密钥的操作日志 - 编辑渠道弹窗新增查看密钥按钮,触发2FA验证模态框 - 使用TwoFactorAuthModal进行验证码输入及验证 - 验证成功后弹出渠道密钥展示窗口 - 对渠道编辑模态框的状态进行了统一重置优化 - 添加相关国际化文案支持密钥查看功能 --- controller/channel.go | 79 ++++++ router/api-router.go | 1 + .../common/modals/TwoFactorAuthModal.jsx | 128 ++++++++++ .../common/ui/ChannelKeyDisplay.jsx | 224 ++++++++++++++++++ .../channels/modals/EditChannelModal.jsx | 200 ++++++++++++++-- web/src/i18n/locales/en.json | 22 +- 6 files changed, 635 insertions(+), 19 deletions(-) create mode 100644 web/src/components/common/modals/TwoFactorAuthModal.jsx create mode 100644 web/src/components/common/ui/ChannelKeyDisplay.jsx diff --git a/controller/channel.go b/controller/channel.go index 020a3327a..70be91d42 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -380,6 +380,85 @@ func GetChannel(c *gin.Context) { return } +// GetChannelKey 验证2FA后获取渠道密钥 +func GetChannelKey(c *gin.Context) { + type GetChannelKeyRequest struct { + Code string `json:"code" binding:"required"` + } + + var req GetChannelKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, fmt.Errorf("参数错误: %v", err)) + return + } + + userId := c.GetInt("id") + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) + return + } + + // 获取2FA记录并验证 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err)) + return + } + + if twoFA == nil || !twoFA.IsEnabled { + common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥")) + return + } + + // 统一的2FA验证逻辑 + if !validateTwoFactorAuth(twoFA, req.Code) { + common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) + return + } + + // 获取渠道信息(包含密钥) + channel, err := model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) + return + } + + if channel == nil { + common.ApiError(c, fmt.Errorf("渠道不存在")) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) + + // 统一的成功响应格式 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": map[string]interface{}{ + "key": channel.Key, + }, + }) +} + +// validateTwoFactorAuth 统一的2FA验证函数 +func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool { + // 尝试验证TOTP + if cleanCode, err := common.ValidateNumericCode(code); err == nil { + if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid { + return true + } + } + + // 尝试验证备用码 + if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid { + return true + } + + return false +} + // validateChannel 通用的渠道校验函数 func validateChannel(channel *model.Channel, isAdd bool) error { // 校验 channel settings diff --git a/router/api-router.go b/router/api-router.go index be721b05f..b3d4fe08e 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -114,6 +114,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/:id", controller.GetChannel) + channelRoute.POST("/:id/key", controller.GetChannelKey) channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx new file mode 100644 index 000000000..a3884d986 --- /dev/null +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -0,0 +1,128 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui'; + +/** + * 可复用的两步验证模态框组件 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {string} props.code - 验证码值 + * @param {boolean} props.loading - 是否正在验证 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + * @param {string} props.placeholder - 输入框占位文本 + */ +const TwoFactorAuthModal = ({ + visible, + code, + loading, + onCodeChange, + onVerify, + onCancel, + title, + description, + placeholder +}) => { + const { t } = useTranslation(); + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && code) { + onVerify(); + } + }; + + return ( + +
+ + + +
+ {title || t('安全验证')} + + } + visible={visible} + onCancel={onCancel} + footer={ + <> + + + + } + width={500} + style={{ maxWidth: '90vw' }} + > +
+ {/* 安全提示 */} +
+
+ + + +
+ + {t('安全验证')} + + + {description || t('为了保护账户安全,请验证您的两步验证码。')} + +
+
+
+ + {/* 验证码输入 */} +
+ + {t('验证身份')} + + + + {t('支持6位TOTP验证码或8位备用码')} + +
+
+
+ ); +}; + +export default TwoFactorAuthModal; \ No newline at end of file diff --git a/web/src/components/common/ui/ChannelKeyDisplay.jsx b/web/src/components/common/ui/ChannelKeyDisplay.jsx new file mode 100644 index 000000000..8c6e79efb --- /dev/null +++ b/web/src/components/common/ui/ChannelKeyDisplay.jsx @@ -0,0 +1,224 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui'; +import { copy, showSuccess } from '../../../helpers'; + +/** + * 解析密钥数据,支持多种格式 + * @param {string} keyData - 密钥数据 + * @param {Function} t - 翻译函数 + * @returns {Array} 解析后的密钥数组 + */ +const parseChannelKeys = (keyData, t) => { + if (!keyData) return []; + + const trimmed = keyData.trim(); + + // 检查是否是JSON数组格式(如Vertex AI) + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((item, index) => ({ + id: index, + content: typeof item === 'string' ? item : JSON.stringify(item, null, 2), + type: typeof item === 'string' ? 'text' : 'json', + label: `${t('密钥')} ${index + 1}` + })); + } + } catch (e) { + // 如果解析失败,按普通文本处理 + console.warn('Failed to parse JSON keys:', e); + } + } + + // 检查是否是多行密钥(按换行符分割) + const lines = trimmed.split('\n').filter(line => line.trim()); + if (lines.length > 1) { + return lines.map((line, index) => ({ + id: index, + content: line.trim(), + type: 'text', + label: `${t('密钥')} ${index + 1}` + })); + } + + // 单个密钥 + return [{ + id: 0, + content: trimmed, + type: trimmed.startsWith('{') ? 'json' : 'text', + label: t('密钥') + }]; +}; + +/** + * 可复用的密钥显示组件 + * @param {Object} props + * @param {string} props.keyData - 密钥数据 + * @param {boolean} props.showSuccessIcon - 是否显示成功图标 + * @param {string} props.successText - 成功文本 + * @param {boolean} props.showWarning - 是否显示安全警告 + * @param {string} props.warningText - 警告文本 + */ +const ChannelKeyDisplay = ({ + keyData, + showSuccessIcon = true, + successText, + showWarning = true, + warningText +}) => { + const { t } = useTranslation(); + + const parsedKeys = parseChannelKeys(keyData, t); + const isMultipleKeys = parsedKeys.length > 1; + + const handleCopyAll = () => { + copy(keyData); + showSuccess(t('所有密钥已复制到剪贴板')); + }; + + const handleCopyKey = (content) => { + copy(content); + showSuccess(t('密钥已复制到剪贴板')); + }; + + return ( +
+ {/* 成功状态 */} + {showSuccessIcon && ( +
+ + + + + {successText || t('验证成功')} + +
+ )} + + {/* 密钥内容 */} +
+
+ + {isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')} + + {isMultipleKeys && ( +
+ + {t('共 {{count}} 个密钥', { count: parsedKeys.length })} + + +
+ )} +
+ +
+ {parsedKeys.map((keyItem) => ( + +
+
+ + {keyItem.label} + +
+ {keyItem.type === 'json' && ( + {t('JSON')} + )} + +
+
+ +
+ + {keyItem.content} + +
+ + {keyItem.type === 'json' && ( + + {t('JSON格式密钥,请确保格式正确')} + + )} +
+
+ ))} +
+ + {isMultipleKeys && ( +
+ + + + + {t('检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。')} + +
+ )} +
+ + {/* 安全警告 */} + {showWarning && ( +
+
+ + + +
+ + {t('安全提醒')} + + + {warningText || t('请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。')} + +
+
+
+ )} +
+ ); +}; + +export default ChannelKeyDisplay; \ No newline at end of file diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index de84da1e1..78c8197f4 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -45,10 +45,13 @@ import { Row, Col, Highlight, + Input, } from '@douyinfe/semi-ui'; import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/ui/JSONEditor'; +import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal'; +import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; import { IconSave, IconClose, @@ -158,6 +161,44 @@ const EditChannelModal = (props) => { const [channelSearchValue, setChannelSearchValue] = useState(''); const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式 const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) + + // 2FA验证查看密钥相关状态 + const [twoFAState, setTwoFAState] = useState({ + showModal: false, + code: '', + loading: false, + showKey: false, + keyData: '' + }); + + // 专门的2FA验证状态(用于TwoFactorAuthModal) + const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false); + const [verifyCode, setVerifyCode] = useState(''); + const [verifyLoading, setVerifyLoading] = useState(false); + + // 2FA状态更新辅助函数 + const updateTwoFAState = (updates) => { + setTwoFAState(prev => ({ ...prev, ...updates })); + }; + + // 重置2FA状态 + const resetTwoFAState = () => { + setTwoFAState({ + showModal: false, + code: '', + loading: false, + showKey: false, + keyData: '' + }); + }; + + // 重置2FA验证状态 + const reset2FAVerifyState = () => { + setShow2FAVerifyModal(false); + setVerifyCode(''); + setVerifyLoading(false); + }; + // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -500,6 +541,42 @@ const EditChannelModal = (props) => { } }; + // 使用TwoFactorAuthModal的验证函数 + const handleVerify2FA = async () => { + if (!verifyCode) { + showError(t('请输入验证码或备用码')); + return; + } + + setVerifyLoading(true); + try { + const res = await API.post(`/api/channel/${channelId}/key`, { + code: verifyCode + }); + if (res.data.success) { + // 验证成功,显示密钥 + updateTwoFAState({ + showModal: true, + showKey: true, + keyData: res.data.data.key + }); + reset2FAVerifyState(); + showSuccess(t('验证成功')); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('获取密钥失败')); + } finally { + setVerifyLoading(false); + } + }; + + // 显示2FA验证模态框 - 使用TwoFactorAuthModal + const handleShow2FAModal = () => { + setShow2FAVerifyModal(true); + }; + useEffect(() => { const modelMap = new Map(); @@ -576,27 +653,37 @@ const EditChannelModal = (props) => { // 重置手动输入模式状态 setUseManualInput(false); } else { - formApiRef.current?.reset(); - // 重置渠道设置状态 - setChannelSettings({ - force_format: false, - thinking_to_content: false, - proxy: '', - pass_through_body_enabled: false, - system_prompt: '', - system_prompt_override: false, - }); - // 重置密钥模式状态 - setKeyMode('append'); - // 清空表单中的key_mode字段 - if (formApiRef.current) { - formApiRef.current.setValue('key_mode', undefined); - } - // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 - setInputs(getInitValues()); + // 统一的模态框关闭重置逻辑 + resetModalState(); } }, [props.visible, channelId]); + // 统一的模态框重置函数 + const resetModalState = () => { + formApiRef.current?.reset(); + // 重置渠道设置状态 + setChannelSettings({ + force_format: false, + thinking_to_content: false, + proxy: '', + pass_through_body_enabled: false, + system_prompt: '', + system_prompt_override: false, + }); + // 重置密钥模式状态 + setKeyMode('append'); + // 清空表单中的key_mode字段 + if (formApiRef.current) { + formApiRef.current.setValue('key_mode', undefined); + } + // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 + setInputs(getInitValues()); + // 重置2FA状态 + resetTwoFAState(); + // 重置2FA验证状态 + reset2FAVerifyState(); + }; + const handleVertexUploadChange = ({ fileList }) => { vertexErroredNames.current.clear(); (async () => { @@ -1080,6 +1167,16 @@ const EditChannelModal = (props) => { {t('追加模式:新密钥将添加到现有密钥列表的末尾')} )} + {isEdit && ( + + )} {batchExtra} } @@ -1154,6 +1251,16 @@ const EditChannelModal = (props) => { {t('追加模式:新密钥将添加到现有密钥列表的末尾')} )} + {isEdit && ( + + )} {batchExtra} } @@ -1194,6 +1301,16 @@ const EditChannelModal = (props) => { {t('追加模式:新密钥将添加到现有密钥列表的末尾')} )} + {isEdit && ( + + )} {batchExtra} } @@ -1846,6 +1963,53 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> + {/* 使用TwoFactorAuthModal组件进行2FA验证 */} + + + {/* 使用ChannelKeyDisplay组件显示密钥 */} + +
+ + + +
+ {t('渠道密钥信息')} + + } + visible={twoFAState.showModal && twoFAState.showKey} + onCancel={resetTwoFAState} + footer={ + + } + width={700} + style={{ maxWidth: '90vw' }} + > + +
+