From da98972ddabcd7a1476e81a3bb511a23169c14f2 Mon Sep 17 00:00:00 2001 From: wzxjohn Date: Fri, 16 May 2025 16:44:47 +0800 Subject: [PATCH 01/77] feat: support UMAMI analytics --- main.go | 18 ++++++++++++++++++ web/index.html | 1 + 2 files changed, 19 insertions(+) diff --git a/main.go b/main.go index 95c6820d7..e9f55b093 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "embed" "fmt" "log" @@ -15,6 +16,7 @@ import ( "one-api/setting/operation_setting" "os" "strconv" + "strings" "github.com/bytedance/gopkg/util/gopool" "github.com/gin-contrib/sessions" @@ -161,6 +163,22 @@ func main() { }) server.Use(sessions.Sessions("session", store)) + analyticsInjectBuilder := &strings.Builder{} + if os.Getenv("UMAMI_WEBSITE_ID") != "" { + umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID") + umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL") + if umamiScriptURL == "" { + umamiScriptURL = "https://analytics.umami.is/script.js" + } + analyticsInjectBuilder.WriteString("") + } + analyticsInject := analyticsInjectBuilder.String() + indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) + router.SetRouter(server, buildFS, indexPage) var port = os.Getenv("PORT") if port == "" { diff --git a/web/index.html b/web/index.html index 1e75f3d74..c6ce7b841 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,7 @@ content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用" /> New API + From ef0780c0968d78fa8971f3864e886edd3a53bc94 Mon Sep 17 00:00:00 2001 From: feitianbubu Date: Sun, 10 Aug 2025 16:34:53 +0800 Subject: [PATCH 02/77] feat: if video cannot play open in a new tab --- .../table/task-logs/modals/ContentModal.jsx | 114 +++++++++++++++++- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx index a6f16c98c..fd17c206f 100644 --- a/web/src/components/table/task-logs/modals/ContentModal.jsx +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -17,8 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; -import { Modal } from '@douyinfe/semi-ui'; +import React, { useState, useEffect } from 'react'; +import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui'; +import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons'; + +const { Text } = Typography; const ContentModal = ({ isModalOpen, @@ -26,17 +29,120 @@ const ContentModal = ({ modalContent, isVideo, }) => { + const [videoError, setVideoError] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (isModalOpen && isVideo) { + setVideoError(false); + setIsLoading(true); + } + }, [isModalOpen, isVideo]); + + const handleVideoError = () => { + setVideoError(true); + setIsLoading(false); + }; + + const handleVideoLoaded = () => { + setIsLoading(false); + }; + + const handleCopyUrl = () => { + navigator.clipboard.writeText(modalContent); + }; + + const handleOpenInNewTab = () => { + window.open(modalContent, '_blank'); + }; + + const renderVideoContent = () => { + if (videoError) { + return ( +
+ + 视频无法在当前浏览器中播放,这可能是由于: + + + • 视频服务商的跨域限制 + + + • 需要特定的请求头或认证 + + + • 防盗链保护机制 + + +
+ + +
+ +
+ + {modalContent} + +
+
+ ); + } + + return ( +
+ {isLoading && ( +
+ +
+ )} +
+ ); + }; + return ( setIsModalOpen(false)} onCancel={() => setIsModalOpen(false)} closable={null} - bodyStyle={{ height: '400px', overflow: 'auto' }} + bodyStyle={{ + height: isVideo ? '450px' : '400px', + overflow: 'auto', + padding: isVideo && videoError ? '0' : '24px' + }} width={800} > {isVideo ? ( - + ); +}; + +export default SecureVerificationModal; \ No newline at end of file diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 15dfbd973..19a83515d 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -26,6 +26,9 @@ import { showInfo, showSuccess, setStatusData, + prepareCredentialCreationOptions, + buildRegistrationResult, + isPasskeySupported, } from '../../helpers'; import { UserContext } from '../../context/User'; import { Modal } from '@douyinfe/semi-ui'; @@ -66,6 +69,10 @@ const PersonalSetting = () => { const [disableButton, setDisableButton] = useState(false); const [countdown, setCountdown] = useState(30); const [systemToken, setSystemToken] = useState(''); + const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false }); + const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false); + const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); const [notificationSettings, setNotificationSettings] = useState({ warningType: 'email', warningThreshold: 100000, @@ -112,6 +119,10 @@ const PersonalSetting = () => { })(); getUserData(); + + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); }, []); useEffect(() => { @@ -160,11 +171,89 @@ const PersonalSetting = () => { } }; + const loadPasskeyStatus = async () => { + try { + const res = await API.get('/api/user/passkey'); + const { success, data, message } = res.data; + if (success) { + setPasskeyStatus({ + enabled: data?.enabled || false, + last_used_at: data?.last_used_at || null, + backup_eligible: data?.backup_eligible || false, + backup_state: data?.backup_state || false, + }); + } else { + showError(message); + } + } catch (error) { + // 忽略错误,保留默认状态 + } + }; + + const handleRegisterPasskey = async () => { + if (!passkeySupported || !window.PublicKeyCredential) { + showInfo(t('当前设备不支持 Passkey')); + return; + } + setPasskeyRegisterLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/register/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || t('无法发起 Passkey 注册')); + return; + } + + const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data); + const credential = await navigator.credentials.create({ publicKey }); + const payload = buildRegistrationResult(credential); + if (!payload) { + showError(t('Passkey 注册失败,请重试')); + return; + } + + const finishRes = await API.post('/api/user/passkey/register/finish', payload); + if (finishRes.data.success) { + showSuccess(t('Passkey 注册成功')); + await loadPasskeyStatus(); + } else { + showError(finishRes.data.message || t('Passkey 注册失败,请重试')); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo(t('已取消 Passkey 注册')); + } else { + showError(t('Passkey 注册失败,请重试')); + } + } finally { + setPasskeyRegisterLoading(false); + } + }; + + const handleRemovePasskey = async () => { + setPasskeyDeleteLoading(true); + try { + const res = await API.delete('/api/user/passkey'); + const { success, message } = res.data; + if (success) { + showSuccess(t('Passkey 已解绑')); + await loadPasskeyStatus(); + } else { + showError(message || t('操作失败,请重试')); + } + } catch (error) { + showError(t('操作失败,请重试')); + } finally { + setPasskeyDeleteLoading(false); + } + }; + const getUserData = async () => { let res = await API.get(`/api/user/self`); const { success, message, data } = res.data; if (success) { userDispatch({ type: 'login', payload: data }); + await loadPasskeyStatus(); } else { showError(message); } @@ -352,6 +441,12 @@ const PersonalSetting = () => { handleSystemTokenClick={handleSystemTokenClick} setShowChangePasswordModal={setShowChangePasswordModal} setShowAccountDeleteModal={setShowAccountDeleteModal} + passkeyStatus={passkeyStatus} + passkeySupported={passkeySupported} + passkeyRegisterLoading={passkeyRegisterLoading} + passkeyDeleteLoading={passkeyDeleteLoading} + onPasskeyRegister={handleRegisterPasskey} + onPasskeyDelete={handleRemovePasskey} /> {/* 右侧:其他设置 */} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index f9a2c019d..abb55301a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -30,6 +30,7 @@ import { Spin, Card, Radio, + Select, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -77,6 +78,13 @@ const SystemSetting = () => { TurnstileSiteKey: '', TurnstileSecretKey: '', RegisterEnabled: '', + 'passkey.enabled': '', + 'passkey.rp_display_name': '', + 'passkey.rp_id': '', + 'passkey.origins': [], + 'passkey.allow_insecure_origin': '', + 'passkey.user_verification': 'preferred', + 'passkey.attachment_preference': '', EmailDomainRestrictionEnabled: '', EmailAliasRestrictionEnabled: '', SMTPSSLEnabled: '', @@ -114,6 +122,7 @@ const SystemSetting = () => { const [domainList, setDomainList] = useState([]); const [ipList, setIpList] = useState([]); const [allowedPorts, setAllowedPorts] = useState([]); + const [passkeyOrigins, setPasskeyOrigins] = useState([]); const getOptions = async () => { setLoading(true); @@ -173,9 +182,28 @@ const SystemSetting = () => { case 'SMTPSSLEnabled': case 'LinuxDOOAuthEnabled': case 'oidc.enabled': + case 'passkey.enabled': + case 'passkey.allow_insecure_origin': case 'WorkerAllowHttpImageRequestEnabled': item.value = toBoolean(item.value); break; + case 'passkey.origins': + try { + const origins = item.value ? JSON.parse(item.value) : []; + setPasskeyOrigins(Array.isArray(origins) ? origins : []); + item.value = Array.isArray(origins) ? origins : []; + } catch (e) { + setPasskeyOrigins([]); + item.value = []; + } + break; + case 'passkey.rp_display_name': + case 'passkey.rp_id': + case 'passkey.user_verification': + case 'passkey.attachment_preference': + // 确保字符串字段不为null/undefined + item.value = item.value || ''; + break; case 'Price': case 'MinTopUp': item.value = parseFloat(item.value); @@ -582,6 +610,45 @@ const SystemSetting = () => { } }; + const submitPasskeySettings = async () => { + const options = []; + + // 只在值有变化时才提交,并确保空值转换为空字符串 + if (originInputs['passkey.rp_display_name'] !== inputs['passkey.rp_display_name']) { + options.push({ + key: 'passkey.rp_display_name', + value: inputs['passkey.rp_display_name'] || '', + }); + } + if (originInputs['passkey.rp_id'] !== inputs['passkey.rp_id']) { + options.push({ + key: 'passkey.rp_id', + value: inputs['passkey.rp_id'] || '', + }); + } + if (originInputs['passkey.user_verification'] !== inputs['passkey.user_verification']) { + options.push({ + key: 'passkey.user_verification', + value: inputs['passkey.user_verification'] || 'preferred', + }); + } + if (originInputs['passkey.attachment_preference'] !== inputs['passkey.attachment_preference']) { + options.push({ + key: 'passkey.attachment_preference', + value: inputs['passkey.attachment_preference'] || '', + }); + } + // Origins总是提交,因为它们可能会被用户清空 + options.push({ + key: 'passkey.origins', + value: JSON.stringify(Array.isArray(passkeyOrigins) ? passkeyOrigins : []), + }); + + if (options.length > 0) { + await updateOptions(options); + } + }; + const handleCheckboxChange = async (optionKey, event) => { const value = event.target.checked; @@ -957,6 +1024,126 @@ const SystemSetting = () => { + + + {t('用以支持基于 WebAuthn 的无密码登录注册')} + + + + + handleCheckboxChange('passkey.enabled', e) + } + > + {t('允许通过 Passkey 登录 & 注册')} + + + + + + + + + + + + + + + + + + + + + + + handleCheckboxChange('passkey.allow_insecure_origin', e) + } + > + {t('允许不安全的 Origin(HTTP)')} + + + + + + {t('允许的 Origins')} + + {t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')} + + { + setPasskeyOrigins(value); + setInputs(prev => ({ + ...prev, + 'passkey.origins': value + })); + }} + placeholder={t('输入 Origin 后回车,如:https://example.com')} + style={{ width: '100%' }} + /> + + + + + + {t('用以防止恶意用户利用临时邮箱批量注册')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 017e7c1e6..b5baa55e5 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -59,6 +59,12 @@ const AccountManagement = ({ handleSystemTokenClick, setShowChangePasswordModal, setShowAccountDeleteModal, + passkeyStatus, + passkeySupported, + passkeyRegisterLoading, + passkeyDeleteLoading, + onPasskeyRegister, + onPasskeyDelete, }) => { const renderAccountInfo = (accountId, label) => { if (!accountId || accountId === '') { @@ -86,6 +92,10 @@ const AccountManagement = ({ }; const isBound = (accountId) => Boolean(accountId); const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); + const passkeyEnabled = passkeyStatus?.enabled; + const lastUsedLabel = passkeyStatus?.last_used_at + ? new Date(passkeyStatus.last_used_at).toLocaleString() + : t('尚未使用'); return ( @@ -476,6 +486,58 @@ const AccountManagement = ({ + {/* Passkey 设置 */} + +
+
+
+ +
+
+ + {t('Passkey 登录')} + + + {passkeyEnabled + ? t('已启用 Passkey,无需密码即可登录') + : t('使用 Passkey 实现免密且更安全的登录体验')} + +
+
+ {t('最后使用时间')}:{lastUsedLabel} +
+ {/*{passkeyEnabled && (*/} + {/*
*/} + {/* {t('备份支持')}:*/} + {/* {passkeyStatus?.backup_eligible*/} + {/* ? t('支持备份')*/} + {/* : t('不支持')}*/} + {/* ,{t('备份状态')}:*/} + {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/} + {/*
*/} + {/*)}*/} + {!passkeySupported && ( +
+ {t('当前设备不支持 Passkey')} +
+ )} +
+
+
+ +
+
+ {/* 两步验证设置 */} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 2eb480e7a..c049fdc20 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -56,8 +56,10 @@ import { } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/ui/JSONEditor'; -import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal'; +import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; +import { useSecureVerification } from '../../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../../services/secureVerification'; import { IconSave, IconClose, @@ -193,43 +195,43 @@ const EditChannelModal = (props) => { const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户 - // 2FA验证查看密钥相关状态 - const [twoFAState, setTwoFAState] = useState({ + // 密钥显示状态 + const [keyDisplayState, setKeyDisplayState] = 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); + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后显示密钥 + if (result.success && result.data?.key) { + setKeyDisplayState({ + showModal: true, + keyData: result.data.key, + }); + } + }, + successMessage: t('密钥获取成功'), + }); - // 2FA状态更新辅助函数 - const updateTwoFAState = (updates) => { - setTwoFAState((prev) => ({ ...prev, ...updates })); - }; - - // 重置2FA状态 - const resetTwoFAState = () => { - setTwoFAState({ + // 重置密钥显示状态 + const resetKeyDisplayState = () => { + setKeyDisplayState({ showModal: false, - code: '', - loading: false, - showKey: false, keyData: '', }); }; - // 重置2FA验证状态 - const reset2FAVerifyState = () => { - setShow2FAVerifyModal(false); - setVerifyCode(''); - setVerifyLoading(false); - }; - // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -602,42 +604,31 @@ const EditChannelModal = (props) => { } }; - // 使用TwoFactorAuthModal的验证函数 - const handleVerify2FA = async () => { - if (!verifyCode) { - showError(t('请输入验证码或备用码')); - return; - } - - setVerifyLoading(true); + // 显示安全验证模态框并开始验证流程 + const handleShow2FAModal = async () => { try { - const res = await API.post(`/api/channel/${channelId}/key`, { - code: verifyCode, + console.log('=== handleShow2FAModal called ==='); + console.log('channelId:', channelId); + console.log('startVerification function:', typeof startVerification); + + // 测试模态框状态 + console.log('Current modal state:', isModalVisible); + + const apiCall = createApiCalls.viewChannelKey(channelId); + console.log('apiCall created:', typeof apiCall); + + const result = await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 优先使用 Passkey }); - if (res.data.success) { - // 验证成功,显示密钥 - updateTwoFAState({ - showModal: true, - showKey: true, - keyData: res.data.data.key, - }); - reset2FAVerifyState(); - showSuccess(t('验证成功')); - } else { - showError(res.data.message); - } + console.log('startVerification result:', result); } catch (error) { - showError(t('获取密钥失败')); - } finally { - setVerifyLoading(false); + console.error('handleShow2FAModal error:', error); + showError(error.message || t('启动验证失败')); } }; - // 显示2FA验证模态框 - 使用TwoFactorAuthModal - const handleShow2FAModal = () => { - setShow2FAVerifyModal(true); - }; - useEffect(() => { const modelMap = new Map(); @@ -741,10 +732,8 @@ const EditChannelModal = (props) => { } // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 setInputs(getInitValues()); - // 重置2FA状态 - resetTwoFAState(); - // 重置2FA验证状态 - reset2FAVerifyState(); + // 重置密钥显示状态 + resetKeyDisplayState(); }; const handleVertexUploadChange = ({ fileList }) => { @@ -2498,17 +2487,17 @@ const EditChannelModal = (props) => { onVisibleChange={(visible) => setIsModalOpenurl(visible)} /> - {/* 使用TwoFactorAuthModal组件进行2FA验证 */} - {/* 使用ChannelKeyDisplay组件显示密钥 */} @@ -2531,10 +2520,10 @@ const EditChannelModal = (props) => { {t('渠道密钥信息')} } - visible={twoFAState.showModal && twoFAState.showKey} - onCancel={resetTwoFAState} + visible={keyDisplayState.showModal} + onCancel={resetKeyDisplayState} footer={ - } @@ -2542,7 +2531,7 @@ const EditChannelModal = (props) => { style={{ maxWidth: '90vw' }} > { @@ -253,6 +255,20 @@ const renderOperations = ( > {t('降级')} + + - - +
+
+ + + + } + style={{ width: '100%' }} + />
- + + + {t('从认证器应用中获取验证码,或使用备用码')} + + +
+ + +
+
)} {hasPasskey && passkeySupported && ( - - - - {t('Passkey')} - - } + tab={t('Passkey')} itemKey='passkey' > - -
-
-
- - - -
- - {t('使用 Passkey 验证')} - - - {t('点击下方按钮,使用您的生物特征或安全密钥进行验证')} - -
-
- - +
+
+
+ + +
+ + {t('使用 Passkey 验证')} + + + {t('点击验证按钮,使用您的生物特征或安全密钥')} +
- + +
+ + +
+
)} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index abb55301a..f0c2dbc3a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -1043,7 +1043,7 @@ const SystemSetting = () => { handleCheckboxChange('passkey.enabled', e) } > - {t('允许通过 Passkey 登录 & 注册')} + {t('允许通过 Passkey 登录 & 认证')} diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 27499f824..54b4525d6 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -206,7 +206,7 @@ const EditChannelModal = (props) => { isModalVisible, verificationMethods, verificationState, - startVerification, + withVerification, executeVerification, cancelVerification, setVerificationCode, @@ -214,12 +214,20 @@ const EditChannelModal = (props) => { } = useSecureVerification({ onSuccess: (result) => { // 验证成功后显示密钥 - if (result.success && result.data?.key) { + console.log('Verification success, result:', result); + if (result && result.success && result.data?.key) { showSuccess(t('密钥获取成功')); setKeyDisplayState({ showModal: true, keyData: result.data.key, }); + } else if (result && result.key) { + // 直接返回了 key(没有包装在 data 中) + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.key, + }); } }, }); @@ -604,19 +612,30 @@ const EditChannelModal = (props) => { } }; - // 显示安全验证模态框并开始验证流程 + // 查看渠道密钥(透明验证) const handleShow2FAModal = async () => { try { - const apiCall = createApiCalls.viewChannelKey(channelId); - - await startVerification(apiCall, { - title: t('查看渠道密钥'), - description: t('为了保护账户安全,请验证您的身份。'), - preferredMethod: 'passkey', // 优先使用 Passkey - }); + // 使用 withVerification 包装,会自动处理需要验证的情况 + const result = await withVerification( + createApiCalls.viewChannelKey(channelId), + { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 优先使用 Passkey + } + ); + + // 如果直接返回了结果(已验证),显示密钥 + if (result && result.success && result.data?.key) { + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.data.key, + }); + } } catch (error) { - console.error('Failed to start verification:', error); - showError(error.message || t('启动验证失败')); + console.error('Failed to view channel key:', error); + showError(error.message || t('获取密钥失败')); } }; diff --git a/web/src/helpers/secureApiCall.js b/web/src/helpers/secureApiCall.js new file mode 100644 index 000000000..b82a6ae92 --- /dev/null +++ b/web/src/helpers/secureApiCall.js @@ -0,0 +1,62 @@ +/* +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 +*/ + +/** + * 安全 API 调用包装器 + * 自动处理需要验证的 403 错误,透明地触发验证流程 + */ + +/** + * 检查错误是否是需要安全验证的错误 + * @param {Error} error - 错误对象 + * @returns {boolean} + */ +export function isVerificationRequiredError(error) { + if (!error.response) return false; + + const { status, data } = error.response; + + // 检查是否是 403 错误且包含验证相关的错误码 + if (status === 403 && data) { + const verificationCodes = [ + 'VERIFICATION_REQUIRED', + 'VERIFICATION_EXPIRED', + 'VERIFICATION_INVALID' + ]; + + return verificationCodes.includes(data.code); + } + + return false; +} + +/** + * 从错误中提取验证需求信息 + * @param {Error} error - 错误对象 + * @returns {Object} 验证需求信息 + */ +export function extractVerificationInfo(error) { + const data = error.response?.data || {}; + + return { + code: data.code, + message: data.message || '需要安全验证', + required: true + }; +} \ No newline at end of file diff --git a/web/src/hooks/common/useSecureVerification.jsx b/web/src/hooks/common/useSecureVerification.jsx index 271345d1c..e60a104db 100644 --- a/web/src/hooks/common/useSecureVerification.jsx +++ b/web/src/hooks/common/useSecureVerification.jsx @@ -21,6 +21,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { SecureVerificationService } from '../../services/secureVerification'; import { showError, showSuccess } from '../../helpers'; +import { isVerificationRequiredError } from '../../helpers/secureApiCall'; /** * 通用安全验证 Hook @@ -82,10 +83,10 @@ export const useSecureVerification = ({ // 开始验证流程 const startVerification = useCallback(async (apiCall, options = {}) => { const { preferredMethod, title, description } = options; - + // 检查验证方式 const methods = await checkVerificationMethods(); - + if (!methods.has2FA && !methods.hasPasskey) { const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作'); showError(errorMessage); @@ -111,7 +112,7 @@ export const useSecureVerification = ({ description })); setIsModalVisible(true); - + return true; }, [checkVerificationMethods, onError, t]); @@ -125,10 +126,11 @@ export const useSecureVerification = ({ setVerificationState(prev => ({ ...prev, loading: true })); try { - const result = await SecureVerificationService.verify(method, { - code, - apiCall: verificationState.apiCall - }); + // 先调用验证 API,成功后后端会设置 session + await SecureVerificationService.verify(method, code); + + // 验证成功,调用业务 API(此时中间件会通过) + const result = await verificationState.apiCall(); // 显示成功消息 if (successMessage) { @@ -191,12 +193,36 @@ export const useSecureVerification = ({ return null; }, [verificationMethods]); + /** + * 包装 API 调用,自动处理验证错误 + * 当 API 返回需要验证的错误时,自动弹出验证模态框 + * @param {Function} apiCall - API 调用函数 + * @param {Object} options - 验证选项(同 startVerification) + * @returns {Promise} + */ + const withVerification = useCallback(async (apiCall, options = {}) => { + try { + // 直接尝试调用 API + return await apiCall(); + } catch (error) { + // 检查是否是需要验证的错误 + if (isVerificationRequiredError(error)) { + // 自动触发验证流程 + await startVerification(apiCall, options); + // 不抛出错误,让验证模态框处理 + return null; + } + // 其他错误继续抛出 + throw error; + } + }, [startVerification]); + return { // 状态 isModalVisible, verificationMethods, verificationState, - + // 方法 startVerification, executeVerification, @@ -205,11 +231,12 @@ export const useSecureVerification = ({ setVerificationCode, switchVerificationMethod, checkVerificationMethods, - + // 辅助方法 canUseMethod, getRecommendedMethod, - + withVerification, // 新增:自动处理验证的包装函数 + // 便捷属性 hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey, isLoading: verificationState.loading, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e221c3b28..5586e0a83 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -333,6 +333,7 @@ "通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password", "允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account", "允许通过微信登录 & 注册": "Allow login & registration via WeChat", + "允许通过 Passkey 登录 & 认证": "Allow login & authentication via Passkey", "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way", "启用 Turnstile 用户校验": "Enable Turnstile user verification", "配置 SMTP": "Configure SMTP", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 26c418205..e6dafac18 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -87,5 +87,6 @@ "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。", "目标用户:{{username}}": "目标用户:{{username}}", "Passkey 已重置": "Passkey 已重置", - "二步验证已重置": "二步验证已重置" + "二步验证已重置": "二步验证已重置", + "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证" } diff --git a/web/src/services/secureVerification.js b/web/src/services/secureVerification.js index 1af53204b..93cdd0a4d 100644 --- a/web/src/services/secureVerification.js +++ b/web/src/services/secureVerification.js @@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com */ import { API, showError } from '../helpers'; -import { - prepareCredentialRequestOptions, - buildAssertionResult, - isPasskeySupported +import { + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported } from '../helpers/passkey'; /** * 通用安全验证服务 + * 验证状态完全由后端 Session 控制,前端不存储任何状态 */ export class SecureVerificationService { /** @@ -81,36 +82,41 @@ export class SecureVerificationService { /** * 执行2FA验证 * @param {string} code - 验证码 - * @param {Function} apiCall - API调用函数,接收 {method: '2fa', code} 参数 - * @returns {Promise} API响应结果 + * @returns {Promise} */ - static async verify2FA(code, apiCall) { + static async verify2FA(code) { if (!code?.trim()) { throw new Error('请输入验证码或备用码'); } - return await apiCall({ + // 调用通用验证 API,验证成功后后端会设置 session + const verifyResponse = await API.post('/api/verify', { method: '2fa', code: code.trim() }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 } /** * 执行Passkey验证 - * @param {Function} apiCall - API调用函数,接收 {method: 'passkey'} 参数 - * @returns {Promise} API响应结果 + * @returns {Promise} */ - static async verifyPasskey(apiCall) { + static async verifyPasskey() { try { // 开始Passkey验证 const beginResponse = await API.post('/api/user/passkey/verify/begin'); - if (!beginResponse.success) { - throw new Error(beginResponse.message); + if (!beginResponse.data?.success) { + throw new Error(beginResponse.data?.message || '开始验证失败'); } // 准备WebAuthn选项 - const publicKey = prepareCredentialRequestOptions(beginResponse.data); - + const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options); + // 执行WebAuthn验证 const credential = await navigator.credentials.get({ publicKey }); if (!credential) { @@ -119,17 +125,23 @@ export class SecureVerificationService { // 构建验证结果 const assertionResult = buildAssertionResult(credential); - + // 完成验证 const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult); - if (!finishResponse.success) { - throw new Error(finishResponse.message); + if (!finishResponse.data?.success) { + throw new Error(finishResponse.data?.message || '验证失败'); } - // 调用业务API - return await apiCall({ + // 调用通用验证 API 设置 session(Passkey 验证已完成) + const verifyResponse = await API.post('/api/verify', { method: 'passkey' }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 } catch (error) { if (error.name === 'NotAllowedError') { throw new Error('Passkey 验证被取消或超时'); @@ -144,17 +156,15 @@ export class SecureVerificationService { /** * 通用验证方法,根据验证类型执行相应的验证流程 * @param {string} method - 验证方式: '2fa' | 'passkey' - * @param {Object} params - 参数对象 - * @param {string} params.code - 2FA验证码(当method为'2fa'时必需) - * @param {Function} params.apiCall - API调用函数 - * @returns {Promise} API响应结果 + * @param {string} code - 2FA验证码(当method为'2fa'时必需) + * @returns {Promise} */ - static async verify(method, { code, apiCall }) { + static async verify(method, code = '') { switch (method) { case '2fa': - return await this.verify2FA(code, apiCall); + return await this.verify2FA(code); case 'passkey': - return await this.verifyPasskey(apiCall); + return await this.verifyPasskey(); default: throw new Error(`不支持的验证方式: ${method}`); } @@ -169,8 +179,10 @@ export const createApiCalls = { * 创建查看渠道密钥的API调用 * @param {number} channelId - 渠道ID */ - viewChannelKey: (channelId) => async (verificationData) => { - return await API.post(`/api/channel/${channelId}/key`, verificationData); + viewChannelKey: (channelId) => async () => { + // 新系统中,验证已通过中间件处理,直接调用 API 即可 + const response = await API.post(`/api/channel/${channelId}/key`, {}); + return response.data; }, /** @@ -179,20 +191,27 @@ export const createApiCalls = { * @param {string} method - HTTP方法,默认为 'POST' * @param {Object} extraData - 额外的请求数据 */ - custom: (url, method = 'POST', extraData = {}) => async (verificationData) => { - const data = { ...extraData, ...verificationData }; - + custom: (url, method = 'POST', extraData = {}) => async () => { + // 新系统中,验证已通过中间件处理 + const data = extraData; + + let response; switch (method.toUpperCase()) { case 'GET': - return await API.get(url, { params: data }); + response = await API.get(url, { params: data }); + break; case 'POST': - return await API.post(url, data); + response = await API.post(url, data); + break; case 'PUT': - return await API.put(url, data); + response = await API.put(url, data); + break; case 'DELETE': - return await API.delete(url, { data }); + response = await API.delete(url, { data }); + break; default: throw new Error(`不支持的HTTP方法: ${method}`); } + return response.data; } }; \ No newline at end of file From 013a575541b7da22a2a4ada911bbd82fc79da0ef Mon Sep 17 00:00:00 2001 From: Seefs Date: Tue, 30 Sep 2025 12:26:24 +0800 Subject: [PATCH 62/77] fix: personal setting --- model/passkey.go | 5 +++-- .../personal/cards/AccountManagement.jsx | 21 +++++++++++++++---- web/src/i18n/locales/en.json | 3 +++ web/src/i18n/locales/zh.json | 5 ++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/model/passkey.go b/model/passkey.go index 092639019..3f45e1764 100644 --- a/model/passkey.go +++ b/model/passkey.go @@ -141,9 +141,10 @@ func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { var credential PasskeyCredential if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - common.SysLog(fmt.Sprintf("GetPasskeyByUserID: passkey not found for user %d", userID)) - return nil, ErrFriendlyPasskeyNotFound + // 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志 + return nil, ErrPasskeyNotFound } + // 只有真正的数据库错误才记录日志 common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) return nil, ErrFriendlyPasskeyNotFound } diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index b5baa55e5..93a2daf89 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -525,10 +525,23 @@ const AccountManagement = ({
- - - + + + + ), + }, + ]; + return ( - (refForm.current = formAPI)} - style={{ marginBottom: 15 }} - > + - { - return verifyJSON(value); - }, - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ - ...inputs, - Chats: value, - }) - } - /> + + + +
+ + {t('编辑模式')}: + + { + const newMode = e.target.value; + setEditMode(newMode); + + // 确保模式切换时数据正确同步 + setTimeout(() => { + if (newMode === 'json' && refForm.current) { + refForm.current.setValues(inputs); + } + }, 100); + }} + > + {t('可视化编辑')} + {t('JSON编辑')} + +
+ + {editMode === 'visual' ? ( +
+ + + } + placeholder={t('搜索聊天应用名称')} + value={searchText} + onChange={(value) => setSearchText(value)} + style={{ width: 250 }} + showClear + /> + + + + t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', { + total, + start: range[0], + end: range[1], + }), + }} + /> + + ) : ( + (refForm.current = formAPI)} + > + { + return verifyJSON(value); + }, + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ + ...inputs, + Chats: value, + }) + } + /> + + )} - - - + + + + + + +
(modalFormRef.current = api)}> + + + + +
); } From 14283385467676ef10bbec65ec1fe145d0d73ee6 Mon Sep 17 00:00:00 2001 From: HynoR <20227709+HynoR@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:40:02 +0800 Subject: [PATCH 72/77] feat: Enhance SettingsChats edit interface --- web/src/pages/Setting/Chat/SettingsChats.jsx | 69 +++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/web/src/pages/Setting/Chat/SettingsChats.jsx b/web/src/pages/Setting/Chat/SettingsChats.jsx index 368a66f5b..01591c782 100644 --- a/web/src/pages/Setting/Chat/SettingsChats.jsx +++ b/web/src/pages/Setting/Chat/SettingsChats.jsx @@ -36,6 +36,7 @@ import { IconEdit, IconDelete, IconSearch, + IconSaveStroked, } from '@douyinfe/semi-icons'; import { compareObjects, @@ -55,7 +56,7 @@ export default function SettingsChats(props) { }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); - const [editMode, setEditMode] = useState('json'); + const [editMode, setEditMode] = useState('visual'); const [chatConfigs, setChatConfigs] = useState([]); const [modalVisible, setModalVisible] = useState(false); const [editingConfig, setEditingConfig] = useState(null); @@ -167,7 +168,9 @@ export default function SettingsChats(props) { } setInputs(currentInputs); setInputsRow(structuredClone(currentInputs)); - refForm.current.setValues(currentInputs); + if (refForm.current) { + refForm.current.setValues(currentInputs); + } // 同步到可视化配置 const configs = jsonToConfigs(currentInputs.Chats || '[]'); @@ -220,6 +223,18 @@ export default function SettingsChats(props) { modalFormRef.current .validate() .then((values) => { + // 检查名称是否重复 + const isDuplicate = chatConfigs.some( + (config) => + config.name === values.name && + (!isEdit || config.id !== editingConfig.id) + ); + + if (isDuplicate) { + showError(t('聊天应用名称已存在,请使用其他名称')); + return; + } + if (isEdit) { const newConfigs = chatConfigs.map((config) => config.id === editingConfig.id @@ -263,6 +278,28 @@ export default function SettingsChats(props) { config.name.toLowerCase().includes(searchText.toLowerCase()), ); + const highlightKeywords = (text) => { + if (!text) return text; + + const parts = text.split(/(\{address\}|\{key\})/g); + return parts.map((part, index) => { + if (part === '{address}') { + return ( + + {part} + + ); + } else if (part === '{key}') { + return ( + + {part} + + ); + } + return part; + }); + }; + const columns = [ { title: t('聊天应用名称'), @@ -275,7 +312,9 @@ export default function SettingsChats(props) { dataIndex: 'url', key: 'url', render: (text) => ( -
{text}
+
+ {highlightKeywords(text)} +
), }, { @@ -351,6 +390,14 @@ export default function SettingsChats(props) { > {t('添加聊天配置')} + } placeholder={t('搜索聊天应用名称')} @@ -410,11 +457,17 @@ export default function SettingsChats(props) { )} - - - + {editMode === 'json' && ( + + + + )} Date: Wed, 1 Oct 2025 19:15:00 +0800 Subject: [PATCH 73/77] feat: add Gotify notification option for quota alerts --- controller/user.go | 51 +++++++- dto/user_settings.go | 4 + service/quota.go | 5 +- service/user_notify.go | 116 +++++++++++++++++- .../components/settings/PersonalSetting.jsx | 15 +++ .../personal/cards/NotificationSettings.jsx | 102 +++++++++++++++ 6 files changed, 287 insertions(+), 6 deletions(-) diff --git a/controller/user.go b/controller/user.go index c03afa322..33d4636b7 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct { WebhookSecret string `json:"webhook_secret,omitempty"` NotificationEmail string `json:"notification_email,omitempty"` BarkUrl string `json:"bark_url,omitempty"` + GotifyUrl string `json:"gotify_url,omitempty"` + GotifyToken string `json:"gotify_token,omitempty"` + GotifyPriority int `json:"gotify_priority,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` RecordIpLog bool `json:"record_ip_log"` } @@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) { } // 验证预警类型 - if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark { + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的预警类型", @@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是Gotify类型,验证Gotify URL和Token + if req.QuotaWarningType == dto.NotifyTypeGotify { + if req.GotifyUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址不能为空", + }) + return + } + if req.GotifyToken == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify令牌不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Gotify服务器地址", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址必须以http://或https://开头", + }) + return + } + } + userId := c.GetInt("id") user, err := model.GetUserById(userId, true) if err != nil { @@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) { settings.BarkUrl = req.BarkUrl } + // 如果是Gotify类型,添加Gotify配置到设置中 + if req.QuotaWarningType == dto.NotifyTypeGotify { + settings.GotifyUrl = req.GotifyUrl + settings.GotifyToken = req.GotifyToken + // Gotify优先级范围0-10,超出范围则使用默认值5 + if req.GotifyPriority < 0 || req.GotifyPriority > 10 { + settings.GotifyPriority = 5 + } else { + settings.GotifyPriority = req.GotifyPriority + } + } + // 更新用户设置 user.SetSetting(settings) if err := user.Update(false); err != nil { diff --git a/dto/user_settings.go b/dto/user_settings.go index 89dd926ef..16ce7b985 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -7,6 +7,9 @@ type UserSetting struct { WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL + GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址 + GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌 + GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级 AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 @@ -16,4 +19,5 @@ var ( NotifyTypeEmail = "email" // Email 邮件 NotifyTypeWebhook = "webhook" // Webhook NotifyTypeBark = "bark" // Bark 推送 + NotifyTypeGotify = "gotify" // Gotify 推送 ) diff --git a/service/quota.go b/service/quota.go index 12017e11e..43c4024ae 100644 --- a/service/quota.go +++ b/service/quota.go @@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon // Bark推送使用简短文本,不支持HTML content = "{{value}},剩余额度:{{value}},请及时充值" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else if notifyType == dto.NotifyTypeGotify { + content = "{{value}},当前剩余额度为 {{value}},请及时充值。" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} } else { - // 默认内容格式,适用于Email和Webhook + // 默认内容格式,适用于Email和Webhook(支持HTML) content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink} } diff --git a/service/user_notify.go b/service/user_notify.go index fba12d9db..0f92e7d75 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -1,6 +1,8 @@ package service import ( + "bytes" + "encoding/json" "fmt" "net/http" "net/url" @@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data switch notifyType { case dto.NotifyTypeEmail: - // check setting email - userEmail = userSetting.NotificationEmail - if userEmail == "" { + // 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱 + emailToUse := userSetting.NotificationEmail + if emailToUse == "" { + emailToUse = userEmail + } + if emailToUse == "" { common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId)) return nil } - return sendEmailNotify(userEmail, data) + return sendEmailNotify(emailToUse, data) case dto.NotifyTypeWebhook: webhookURLStr := userSetting.WebhookUrl if webhookURLStr == "" { @@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data return nil } return sendBarkNotify(barkURL, data) + case dto.NotifyTypeGotify: + gotifyUrl := userSetting.GotifyUrl + gotifyToken := userSetting.GotifyToken + if gotifyUrl == "" || gotifyToken == "" { + common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId)) + return nil + } + return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data) } return nil } @@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { return nil } + +func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 构建完整的 Gotify API URL + // 确保 URL 以 /message 结尾 + finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken) + + // Gotify优先级范围0-10,如果超出范围则使用默认值5 + if priority < 0 || priority > 10 { + priority = 5 + } + + // 构建 JSON payload + type GotifyMessage struct { + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + } + + payload := GotifyMessage{ + Title: data.Title, + Message: content, + Priority: priority, + } + + // 序列化为 JSON + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal gotify payload: %v", err) + } + + var req *http.Request + var resp *http.Response + + if system_setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "OneAPI-Gotify-Notify/1.0", + }, + Body: payloadBytes, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send gotify request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Gotify URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + // 直接发送请求 + req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create gotify request: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send gotify request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 01e7023ad..c9934604c 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -81,6 +81,9 @@ const PersonalSetting = () => { webhookSecret: '', notificationEmail: '', barkUrl: '', + gotifyUrl: '', + gotifyToken: '', + gotifyPriority: 5, acceptUnsetModelRatioModel: false, recordIpLog: false, }); @@ -149,6 +152,12 @@ const PersonalSetting = () => { webhookSecret: settings.webhook_secret || '', notificationEmail: settings.notification_email || '', barkUrl: settings.bark_url || '', + gotifyUrl: settings.gotify_url || '', + gotifyToken: settings.gotify_token || '', + gotifyPriority: + settings.gotify_priority !== undefined + ? settings.gotify_priority + : 5, acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, recordIpLog: settings.record_ip_log || false, @@ -406,6 +415,12 @@ const PersonalSetting = () => { webhook_secret: notificationSettings.webhookSecret, notification_email: notificationSettings.notificationEmail, bark_url: notificationSettings.barkUrl, + gotify_url: notificationSettings.gotifyUrl, + gotify_token: notificationSettings.gotifyToken, + gotify_priority: (() => { + const parsed = parseInt(notificationSettings.gotifyPriority); + return isNaN(parsed) ? 5 : parsed; + })(), accept_unset_model_ratio_model: notificationSettings.acceptUnsetModelRatioModel, record_ip_log: notificationSettings.recordIpLog, diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index aad612d2c..dc428f145 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -400,6 +400,7 @@ const NotificationSettings = ({ {t('邮件通知')} {t('Webhook通知')} {t('Bark通知')} + {t('Gotify通知')} )} + + {/* Gotify推送设置 */} + {notificationSettings.warningType === 'gotify' && ( + <> + handleFormChange('gotifyUrl', val)} + prefix={} + extraText={t( + '支持HTTP和HTTPS,填写Gotify服务器的完整URL地址', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify服务器地址'), + }, + { + pattern: /^https?:\/\/.+/, + message: t('Gotify服务器地址必须以http://或https://开头'), + }, + ]} + /> + + handleFormChange('gotifyToken', val)} + prefix={} + extraText={t( + '在Gotify服务器创建应用后获得的令牌,用于发送通知', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify应用令牌'), + }, + ]} + /> + + + handleFormChange('gotifyPriority', val) + } + prefix={} + extraText={t('消息优先级,范围0-10,默认为5')} + style={{ width: '100%', maxWidth: '300px' }} + /> + +
+
+ {t('配置说明')} +
+
+
+ 1. {t('在Gotify服务器的应用管理中创建新应用')} +
+
+ 2.{' '} + {t( + '复制应用的令牌(Token)并填写到上方的应用令牌字段', + )} +
+
+ 3. {t('填写Gotify服务器的完整URL地址')} +
+
+ + {t('更多信息请参考')} + {' '} + + Gotify 官方文档 + +
+
+
+ + )} From d6db10b4bc5aeba46e400d0146fb527516d2304f Mon Sep 17 00:00:00 2001 From: RedwindA Date: Wed, 1 Oct 2025 19:36:19 +0800 Subject: [PATCH 74/77] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Bark=20?= =?UTF-8?q?=E5=92=8C=20Gotify=20=E9=80=9A=E7=9F=A5=E7=9A=84=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E5=8C=96=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../personal/cards/NotificationSettings.jsx | 4 +-- web/src/i18n/locales/en.json | 32 +++++++++++++++++++ web/src/i18n/locales/fr.json | 32 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index dc428f145..0c99e2855 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -590,7 +590,7 @@ const NotificationSettings = ({ rel='noopener noreferrer' className='text-blue-500 hover:text-blue-600 font-medium' > - Bark 官方文档 + Bark {t('官方文档')} @@ -691,7 +691,7 @@ const NotificationSettings = ({ rel='noopener noreferrer' className='text-blue-500 hover:text-blue-600 font-medium' > - Gotify 官方文档 + Gotify {t('官方文档')} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1e1064b56..7ba76a0de 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1313,6 +1313,8 @@ "请输入Webhook地址,例如: https://example.com/webhook": "Please enter the Webhook URL, e.g.: https://example.com/webhook", "邮件通知": "Email notification", "Webhook通知": "Webhook notification", + "Bark通知": "Bark notification", + "Gotify通知": "Gotify notification", "接口凭证(可选)": "Interface credentials (optional)", "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "The secret will be added to the request header as a Bearer token to verify the legitimacy of the webhook request", "Authorization: Bearer your-secret-key": "Authorization: Bearer your-secret-key", @@ -1323,6 +1325,36 @@ "通知邮箱": "Notification email", "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used", "留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used", + "Bark推送URL": "Bark Push URL", + "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Please enter Bark push URL, e.g.: https://api.day.app/yourkey/{{title}}/{{content}}", + "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Supports HTTP and HTTPS, template variables: {{title}} (notification title), {{content}} (notification content)", + "请输入Bark推送URL": "Please enter Bark push URL", + "Bark推送URL必须以http://或https://开头": "Bark push URL must start with http:// or https://", + "模板示例": "Template example", + "更多参数请参考": "For more parameters, please refer to", + "Gotify服务器地址": "Gotify server address", + "请输入Gotify服务器地址,例如: https://gotify.example.com": "Please enter Gotify server address, e.g.: https://gotify.example.com", + "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Supports HTTP and HTTPS, enter the complete URL of the Gotify server", + "请输入Gotify服务器地址": "Please enter Gotify server address", + "Gotify服务器地址必须以http://或https://开头": "Gotify server address must start with http:// or https://", + "Gotify应用令牌": "Gotify application token", + "请输入Gotify应用令牌": "Please enter Gotify application token", + "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Token obtained after creating an application on the Gotify server, used to send notifications", + "消息优先级": "Message priority", + "请选择消息优先级": "Please select message priority", + "0 - 最低": "0 - Lowest", + "2 - 低": "2 - Low", + "5 - 正常(默认)": "5 - Normal (default)", + "8 - 高": "8 - High", + "10 - 最高": "10 - Highest", + "消息优先级,范围0-10,默认为5": "Message priority, range 0-10, default is 5", + "配置说明": "Configuration instructions", + "在Gotify服务器的应用管理中创建新应用": "Create a new application in the Gotify server's application management", + "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copy the application token and fill it in the application token field above", + "填写Gotify服务器的完整URL地址": "Fill in the complete URL address of the Gotify server", + "更多信息请参考": "For more information, please refer to", + "通知内容": "Notification content", + "官方文档": "Official documentation", "API地址": "Base URL", "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in", "渠道额外设置": "Channel extra settings", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 3a216e53b..6dde55977 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1308,6 +1308,8 @@ "请输入Webhook地址,例如: https://example.com/webhook": "Veuillez saisir l'URL du Webhook, par exemple : https://example.com/webhook", "邮件通知": "Notification par e-mail", "Webhook通知": "Notification par Webhook", + "Bark通知": "Notification Bark", + "Gotify通知": "Notification Gotify", "接口凭证(可选)": "Informations d'identification de l'interface (facultatif)", "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "Le secret sera ajouté à l'en-tête de la requête en tant que jeton Bearer pour vérifier la légitimité de la requête webhook", "Authorization: Bearer your-secret-key": "Autorisation : Bearer votre-clé-secrète", @@ -1318,6 +1320,36 @@ "通知邮箱": "E-mail de notification", "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Définissez l'adresse e-mail pour recevoir les notifications d'avertissement de quota, si elle n'est pas définie, l'adresse e-mail liée au compte sera utilisée", "留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée", + "Bark推送URL": "URL de notification Bark", + "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Veuillez saisir l'URL de notification Bark, par exemple : https://api.day.app/yourkey/{{title}}/{{content}}", + "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Prend en charge HTTP et HTTPS, variables de modèle : {{title}} (titre de la notification), {{content}} (contenu de la notification)", + "请输入Bark推送URL": "Veuillez saisir l'URL de notification Bark", + "Bark推送URL必须以http://或https://开头": "L'URL de notification Bark doit commencer par http:// ou https://", + "模板示例": "Exemple de modèle", + "更多参数请参考": "Pour plus de paramètres, veuillez vous référer à", + "Gotify服务器地址": "Adresse du serveur Gotify", + "请输入Gotify服务器地址,例如: https://gotify.example.com": "Veuillez saisir l'adresse du serveur Gotify, par exemple : https://gotify.example.com", + "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Prend en charge HTTP et HTTPS, saisissez l'URL complète du serveur Gotify", + "请输入Gotify服务器地址": "Veuillez saisir l'adresse du serveur Gotify", + "Gotify服务器地址必须以http://或https://开头": "L'adresse du serveur Gotify doit commencer par http:// ou https://", + "Gotify应用令牌": "Jeton d'application Gotify", + "请输入Gotify应用令牌": "Veuillez saisir le jeton d'application Gotify", + "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Jeton obtenu après la création d'une application sur le serveur Gotify, utilisé pour envoyer des notifications", + "消息优先级": "Priorité du message", + "请选择消息优先级": "Veuillez sélectionner la priorité du message", + "0 - 最低": "0 - La plus basse", + "2 - 低": "2 - Basse", + "5 - 正常(默认)": "5 - Normale (par défaut)", + "8 - 高": "8 - Haute", + "10 - 最高": "10 - La plus haute", + "消息优先级,范围0-10,默认为5": "Priorité du message, plage 0-10, par défaut 5", + "配置说明": "Instructions de configuration", + "在Gotify服务器的应用管理中创建新应用": "Créer une nouvelle application dans la gestion des applications du serveur Gotify", + "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copier le jeton de l'application et le remplir dans le champ de jeton d'application ci-dessus", + "填写Gotify服务器的完整URL地址": "Remplir l'adresse URL complète du serveur Gotify", + "更多信息请参考": "Pour plus d'informations, veuillez vous référer à", + "通知内容": "Contenu de la notification", + "官方文档": "Documentation officielle", "API地址": "URL de base", "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir", "渠道额外设置": "Paramètres supplémentaires du canal", From 2200bb9166e20bca3168273e04ce039e7210e075 Mon Sep 17 00:00:00 2001 From: RedwindA Date: Wed, 1 Oct 2025 22:19:22 +0800 Subject: [PATCH 75/77] fix(openai): add nil checks for web_search streaming to prevent panic --- relay/channel/openai/relay_responses.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 85938a771..7b148f323 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp if streamResponse.Item != nil { switch streamResponse.Item.Type { case dto.BuildInCallWebSearchCall: - info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++ + if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { + if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { + webSearchTool.CallCount++ + } + } } } } From 0e9ad4a15f6cee8b3c0193215db1fa6bb7d5453d Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 00:14:35 +0800 Subject: [PATCH 76/77] fix: missing field & field control --- dto/channel_settings.go | 3 + dto/claude.go | 5 +- dto/openai_request.go | 39 ++++-- relay/claude_handler.go | 6 + relay/common/relay_info.go | 34 +++++ relay/compatible_handler.go | 6 + relay/responses_handler.go | 7 + .../channels/modals/EditChannelModal.jsx | 125 ++++++++++++++++-- web/src/i18n/locales/en.json | 7 + web/src/i18n/locales/fr.json | 7 + 10 files changed, 213 insertions(+), 26 deletions(-) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index d6d6e0848..d57184b38 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -20,6 +20,9 @@ type ChannelOtherSettings struct { AzureResponsesVersion string `json:"azure_responses_version,omitempty"` VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { diff --git a/dto/claude.go b/dto/claude.go index 427742263..dfc5cfd4c 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -195,12 +195,15 @@ type ClaudeRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` TopK int `json:"top_k,omitempty"` - //ClaudeMetadata `json:"metadata,omitempty"` Stream bool `json:"stream,omitempty"` Tools any `json:"tools,omitempty"` ContextManagement json.RawMessage `json:"context_management,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` Thinking *Thinking `json:"thinking,omitempty"` + McpServers json.RawMessage `json:"mcp_servers,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` } func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/dto/openai_request.go b/dto/openai_request.go index 191fa638f..dbdfad446 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct { Dimensions int `json:"dimensions,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"` Audio json.RawMessage `json:"audio,omitempty"` + // 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户 + // 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私 + SafetyIdentifier string `json:"safety_identifier,omitempty"` + // Whether or not to store the output of this chat completion request for use in our model distillation or evals products. + // 是否存储此次请求数据供 OpenAI 用于评估和优化产品 + // 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用 + Store json.RawMessage `json:"store,omitempty"` + // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + LogitBias json.RawMessage `json:"logit_bias,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Prediction json.RawMessage `json:"prediction,omitempty"` // gemini ExtraBody json.RawMessage `json:"extra_body,omitempty"` //xai @@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct { ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - Store json.RawMessage `json:"store,omitempty"` - PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - Text json.RawMessage `json:"text,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map - TopP float64 `json:"top_p,omitempty"` - Truncation string `json:"truncation,omitempty"` - User string `json:"user,omitempty"` - MaxToolCalls uint `json:"max_tool_calls,omitempty"` - Prompt json.RawMessage `json:"prompt,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map + TopP float64 `json:"top_p,omitempty"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` + MaxToolCalls uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` } func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 59d12abe4..3a739785f 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for Claude API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f4ffaee23..cb66cd806 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -507,3 +507,37 @@ type TaskInfo struct { Url string `json:"url,omitempty"` Progress string `json:"progress,omitempty"` } + +// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段 +// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持) +// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用) +// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私) +func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + return jsonData, err + } + + // 默认移除 service_tier,除非明确允许(避免额外计费风险) + if !channelOtherSettings.AllowServiceTier { + if _, exists := data["service_tier"]; exists { + delete(data, "service_tier") + } + } + + // 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用) + if channelOtherSettings.DisableStore { + if _, exists := data["store"]; exists { + delete(data, "store") + } + } + + // 默认移除 safety_identifier,除非明确允许(保护用户隐私,避免向 OpenAI 报告用户信息) + if !channelOtherSettings.AllowSafetyIdentifier { + if _, exists := data["safety_identifier"]; exists { + delete(data, "safety_identifier") + } + } + + return common.Marshal(data) +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 38b820f72..a3ddf6d49 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for OpenAI API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 0c57a303f..6958f96ef 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + + // remove disabled fields for OpenAI Responses API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index f625ab14e..571c136f9 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -169,6 +169,10 @@ const EditChannelModal = (props) => { vertex_key_type: 'json', // 企业账户设置 is_enterprise_account: false, + // 字段透传控制默认值 + allow_service_tier: false, + disable_store: false, // false = 允许透传(默认开启) + allow_safety_identifier: false, }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -453,17 +457,27 @@ const EditChannelModal = (props) => { data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; // 读取企业账户设置 data.is_enterprise_account = parsedSettings.openrouter_enterprise === true; + // 读取字段透传控制设置 + data.allow_service_tier = parsedSettings.allow_service_tier || false; + data.disable_store = parsedSettings.disable_store || false; + data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } } else { // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } if ( @@ -900,21 +914,33 @@ const EditChannelModal = (props) => { }; localInputs.setting = JSON.stringify(channelExtraSettings); - // 处理type === 20的企业账户设置 - if (localInputs.type === 20) { - let settings = {}; - if (localInputs.settings) { - try { - settings = JSON.parse(localInputs.settings); - } catch (error) { - console.error('解析settings失败:', error); - } + // 处理 settings 字段(包括企业账户设置和字段透传控制) + let settings = {}; + if (localInputs.settings) { + try { + settings = JSON.parse(localInputs.settings); + } catch (error) { + console.error('解析settings失败:', error); } - // 设置企业账户标识,无论是true还是false都要传到后端 - settings.openrouter_enterprise = localInputs.is_enterprise_account === true; - localInputs.settings = JSON.stringify(settings); } + // type === 20: 设置企业账户标识,无论是true还是false都要传到后端 + if (localInputs.type === 20) { + settings.openrouter_enterprise = localInputs.is_enterprise_account === true; + } + + // type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值) + if (localInputs.type === 1 || localInputs.type === 14) { + settings.allow_service_tier = localInputs.allow_service_tier === true; + // 仅 OpenAI 渠道需要 store 和 safety_identifier + if (localInputs.type === 1) { + settings.disable_store = localInputs.disable_store === true; + settings.allow_safety_identifier = localInputs.allow_safety_identifier === true; + } + } + + localInputs.settings = JSON.stringify(settings); + // 清理不需要发送到后端的字段 delete localInputs.force_format; delete localInputs.thinking_to_content; @@ -925,6 +951,10 @@ const EditChannelModal = (props) => { delete localInputs.is_enterprise_account; // 顶层的 vertex_key_type 不应发送给后端 delete localInputs.vertex_key_type; + // 清理字段透传控制的临时字段 + delete localInputs.allow_service_tier; + delete localInputs.disable_store; + delete localInputs.allow_safety_identifier; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -2384,6 +2414,76 @@ const EditChannelModal = (props) => { '键为原状态码,值为要复写的状态码,仅影响本地判断', )} /> + + {/* 字段透传控制 - OpenAI 渠道 */} + {inputs.type === 1 && ( + <> +
+ {t('字段透传控制')} +
+ + + handleChannelOtherSettingsChange('allow_service_tier', value) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + + handleChannelOtherSettingsChange('disable_store', value) + } + extraText={t( + 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', + )} + /> + + + handleChannelOtherSettingsChange('allow_safety_identifier', value) + } + extraText={t( + 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', + )} + /> + + )} + + {/* 字段透传控制 - Claude 渠道 */} + {(inputs.type === 14) && ( + <> +
+ {t('字段透传控制')} +
+ + + handleChannelOtherSettingsChange('allow_service_tier', value) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + )} {/* Channel Extra Settings Card */} @@ -2487,6 +2587,7 @@ const EditChannelModal = (props) => { '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', )} /> + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1e1064b56..0d940d82b 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2191,6 +2191,13 @@ "输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com", "保存 Passkey 设置": "Save Passkey Settings", "黑名单": "Blacklist", + "字段透传控制": "Field Pass-through Control", + "允许 service_tier 透传": "Allow service_tier Pass-through", + "禁用 store 透传": "Disable store Pass-through", + "允许 safety_identifier 透传": "Allow safety_identifier Pass-through", + "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges", + "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction", + "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy", "common": { "changeLanguage": "Change Language" } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 3a216e53b..f67b88efb 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2135,6 +2135,13 @@ "关闭侧边栏": "Fermer la barre latérale", "定价": "Tarification", "语言": "Langue", + "字段透传控制": "Contrôle du passage des champs", + "允许 service_tier 透传": "Autoriser le passage de service_tier", + "禁用 store 透传": "Désactiver le passage de store", + "允许 safety_identifier 透传": "Autoriser le passage de safety_identifier", + "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires", + "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex", + "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs", "common": { "changeLanguage": "Changer de langue" } From 26a563da54c65f4ade601b567a51da86013e0537 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 2 Oct 2025 13:57:49 +0800 Subject: [PATCH 77/77] fix: Return the original payload and nil error on Unmarshal or Marshal failures in RemoveDisabledFields --- relay/common/relay_info.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index cb66cd806..35f8ad191 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -515,7 +515,8 @@ type TaskInfo struct { func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { var data map[string]interface{} if err := common.Unmarshal(jsonData, &data); err != nil { - return jsonData, err + common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error()) + return jsonData, nil } // 默认移除 service_tier,除非明确允许(避免额外计费风险) @@ -539,5 +540,10 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther } } - return common.Marshal(data) + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil }