Browse Source

Merge pull request #2987 from seefs001/feature/channel-retry-warning

Feature/channel retry warning
Calcium-Ion 1 tuần trước cách đây
mục cha
commit
3523947aba

+ 1 - 1
controller/relay.go

@@ -614,7 +614,7 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError,
 	}
 	if taskErr.StatusCode/100 == 5 {
 		// 超时不重试
-		if taskErr.StatusCode == 504 || taskErr.StatusCode == 524 {
+		if operation_setting.IsAlwaysSkipRetryStatusCode(taskErr.StatusCode) {
 			return false
 		}
 		return true

+ 13 - 0
setting/operation_setting/status_code_ranges.go

@@ -26,6 +26,11 @@ var AutomaticRetryStatusCodeRanges = []StatusCodeRange{
 	{Start: 525, End: 599},
 }
 
+var alwaysSkipRetryStatusCodes = map[int]struct{}{
+	504: {},
+	524: {},
+}
+
 func AutomaticDisableStatusCodesToString() string {
 	return statusCodeRangesToString(AutomaticDisableStatusCodeRanges)
 }
@@ -56,7 +61,15 @@ func AutomaticRetryStatusCodesFromString(s string) error {
 	return nil
 }
 
+func IsAlwaysSkipRetryStatusCode(code int) bool {
+	_, exists := alwaysSkipRetryStatusCodes[code]
+	return exists
+}
+
 func ShouldRetryByStatusCode(code int) bool {
+	if IsAlwaysSkipRetryStatusCode(code) {
+		return false
+	}
 	return shouldMatchStatusCodeRanges(AutomaticRetryStatusCodeRanges, code)
 }
 

+ 8 - 0
setting/operation_setting/status_code_ranges_test.go

@@ -62,6 +62,8 @@ func TestShouldRetryByStatusCode(t *testing.T) {
 
 	require.True(t, ShouldRetryByStatusCode(429))
 	require.True(t, ShouldRetryByStatusCode(500))
+	require.False(t, ShouldRetryByStatusCode(504))
+	require.False(t, ShouldRetryByStatusCode(524))
 	require.False(t, ShouldRetryByStatusCode(400))
 	require.False(t, ShouldRetryByStatusCode(200))
 }
@@ -77,3 +79,9 @@ func TestShouldRetryByStatusCode_DefaultMatchesLegacyBehavior(t *testing.T) {
 	require.False(t, ShouldRetryByStatusCode(524))
 	require.True(t, ShouldRetryByStatusCode(599))
 }
+
+func TestIsAlwaysSkipRetryStatusCode(t *testing.T) {
+	require.True(t, IsAlwaysSkipRetryStatusCode(504))
+	require.True(t, IsAlwaysSkipRetryStatusCode(524))
+	require.False(t, IsAlwaysSkipRetryStatusCode(500))
+}

+ 225 - 0
web/src/components/common/modals/RiskAcknowledgementModal.jsx

@@ -0,0 +1,225 @@
+/*
+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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+  Modal,
+  Button,
+  Typography,
+  Checkbox,
+  Input,
+  Space,
+} from '@douyinfe/semi-ui';
+import { IconAlertTriangle } from '@douyinfe/semi-icons';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+import MarkdownRenderer from '../markdown/MarkdownRenderer';
+
+const { Text } = Typography;
+
+const RiskMarkdownBlock = React.memo(function RiskMarkdownBlock({
+  markdownContent,
+}) {
+  if (!markdownContent) {
+    return null;
+  }
+
+  return (
+    <div
+      className='rounded-lg'
+      style={{
+        border: '1px solid var(--semi-color-warning-light-hover)',
+        background:
+          'linear-gradient(180deg, var(--semi-color-warning-light-default) 0%, var(--semi-color-fill-0) 100%)',
+        padding: '12px',
+        contentVisibility: 'auto',
+      }}
+    >
+      <MarkdownRenderer content={markdownContent} />
+    </div>
+  );
+});
+
+const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
+  visible,
+  title,
+  markdownContent = '',
+  detailTitle = '',
+  detailItems = [],
+  checklist = [],
+  inputPrompt = '',
+  requiredText = '',
+  inputPlaceholder = '',
+  mismatchText = '',
+  cancelText = '',
+  confirmText = '',
+  onCancel,
+  onConfirm,
+}) {
+  const isMobile = useIsMobile();
+  const [checkedItems, setCheckedItems] = useState([]);
+  const [typedText, setTypedText] = useState('');
+
+  useEffect(() => {
+    if (!visible) return;
+    setCheckedItems(Array(checklist.length).fill(false));
+    setTypedText('');
+  }, [visible, checklist.length]);
+
+  const allChecked = useMemo(() => {
+    if (checklist.length === 0) return true;
+    return checkedItems.length === checklist.length && checkedItems.every(Boolean);
+  }, [checkedItems, checklist.length]);
+
+  const typedMatched = useMemo(() => {
+    if (!requiredText) return true;
+    return typedText.trim() === requiredText.trim();
+  }, [typedText, requiredText]);
+
+  const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
+  const canConfirm = allChecked && typedMatched;
+
+  const handleChecklistChange = useCallback((index, checked) => {
+    setCheckedItems((previous) => {
+      const next = [...previous];
+      next[index] = checked;
+      return next;
+    });
+  }, []);
+
+  return (
+    <Modal
+      visible={visible}
+      title={
+        <Space align='center'>
+          <IconAlertTriangle style={{ color: 'var(--semi-color-warning)' }} />
+          <span>{title}</span>
+        </Space>
+      }
+      width={isMobile ? '100%' : 860}
+      centered
+      maskClosable={false}
+      closeOnEsc={false}
+      onCancel={onCancel}
+      bodyStyle={{
+        maxHeight: isMobile ? '70vh' : '72vh',
+        overflowY: 'auto',
+        padding: isMobile ? '12px 16px' : '18px 22px',
+      }}
+      footer={
+        <Space>
+          <Button onClick={onCancel}>{cancelText}</Button>
+          <Button
+            theme='solid'
+            type='danger'
+            disabled={!canConfirm}
+            onClick={onConfirm}
+          >
+            {confirmText}
+          </Button>
+        </Space>
+      }
+    >
+      <div className='flex flex-col gap-4'>
+        <div
+          className='rounded-lg'
+          style={{
+            border: '1px solid var(--semi-color-warning-light-hover)',
+            background: 'var(--semi-color-warning-light-default)',
+            padding: isMobile ? '10px 12px' : '12px 14px',
+          }}
+        >
+        </div>
+
+        <RiskMarkdownBlock markdownContent={markdownContent} />
+
+        {detailItems.length > 0 ? (
+          <div
+            className='flex flex-col gap-2 rounded-lg'
+            style={{
+              border: '1px solid var(--semi-color-warning-light-hover)',
+              background: 'var(--semi-color-fill-0)',
+              padding: isMobile ? '10px 12px' : '12px 14px',
+            }}
+          >
+            {detailTitle ? <Text strong>{detailTitle}</Text> : null}
+            <div className='font-mono text-xs break-all bg-orange-50 border border-orange-200 rounded-md p-2'>
+              {detailText}
+            </div>
+          </div>
+        ) : null}
+
+        {checklist.length > 0 ? (
+          <div
+            className='flex flex-col gap-2 rounded-lg'
+            style={{
+              border: '1px solid var(--semi-color-border)',
+              background: 'var(--semi-color-fill-0)',
+              padding: isMobile ? '10px 12px' : '12px 14px',
+            }}
+          >
+            {checklist.map((item, index) => (
+              <Checkbox
+                key={`risk-check-${index}`}
+                checked={!!checkedItems[index]}
+                onChange={(event) => {
+                  handleChecklistChange(index, event.target.checked);
+                }}
+              >
+                {item}
+              </Checkbox>
+            ))}
+          </div>
+        ) : null}
+
+        {requiredText ? (
+          <div
+            className='flex flex-col gap-2 rounded-lg'
+            style={{
+              border: '1px solid var(--semi-color-danger-light-hover)',
+              background: 'var(--semi-color-danger-light-default)',
+              padding: isMobile ? '10px 12px' : '12px 14px',
+            }}
+          >
+            {inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
+            <div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
+              {requiredText}
+            </div>
+            <Input
+              value={typedText}
+              onChange={setTypedText}
+              placeholder={inputPlaceholder}
+              autoFocus={visible}
+              onCopy={(event) => event.preventDefault()}
+              onCut={(event) => event.preventDefault()}
+              onPaste={(event) => event.preventDefault()}
+              onDrop={(event) => event.preventDefault()}
+            />
+            {!typedMatched && typedText ? (
+              <Text type='danger' size='small'>
+                {mismatchText}
+              </Text>
+            ) : null}
+          </div>
+        ) : null}
+      </div>
+    </Modal>
+  );
+});
+
+export default RiskAcknowledgementModal;

+ 54 - 0
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -61,9 +61,11 @@ import OllamaModelModal from './OllamaModelModal';
 import CodexOAuthModal from './CodexOAuthModal';
 import JSONEditor from '../../../common/ui/JSONEditor';
 import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
+import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
 import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
 import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
 import { createApiCalls } from '../../../../services/secureVerification';
+import { collectNewDisallowedStatusCodeRedirects } from './statusCodeRiskGuard';
 import {
   IconSave,
   IconClose,
@@ -255,6 +257,12 @@ const EditChannelModal = (props) => {
     window.open(targetUrl, '_blank', 'noopener');
   };
   const [verifyLoading, setVerifyLoading] = useState(false);
+  const statusCodeRiskConfirmResolverRef = useRef(null);
+  const [statusCodeRiskConfirmVisible, setStatusCodeRiskConfirmVisible] =
+    useState(false);
+  const [statusCodeRiskDetailItems, setStatusCodeRiskDetailItems] = useState(
+    [],
+  );
 
   // 表单块导航相关状态
   const formSectionRefs = useRef({
@@ -276,6 +284,7 @@ const EditChannelModal = (props) => {
   const doubaoApiClickCountRef = useRef(0);
   const initialModelsRef = useRef([]);
   const initialModelMappingRef = useRef('');
+  const initialStatusCodeMappingRef = useRef('');
 
   // 2FA状态更新辅助函数
   const updateTwoFAState = (updates) => {
@@ -691,6 +700,7 @@ const EditChannelModal = (props) => {
         .map((model) => (model || '').trim())
         .filter(Boolean);
       initialModelMappingRef.current = data.model_mapping || '';
+      initialStatusCodeMappingRef.current = data.status_code_mapping || '';
 
       let parsedIonet = null;
       if (data.other_info) {
@@ -1017,11 +1027,22 @@ const EditChannelModal = (props) => {
     if (!isEdit) {
       initialModelsRef.current = [];
       initialModelMappingRef.current = '';
+      initialStatusCodeMappingRef.current = '';
     }
   }, [isEdit, props.visible]);
 
+  useEffect(() => {
+    return () => {
+      if (statusCodeRiskConfirmResolverRef.current) {
+        statusCodeRiskConfirmResolverRef.current(false);
+        statusCodeRiskConfirmResolverRef.current = null;
+      }
+    };
+  }, []);
+
   // 统一的模态框重置函数
   const resetModalState = () => {
+    resolveStatusCodeRiskConfirm(false);
     formApiRef.current?.reset();
     // 重置渠道设置状态
     setChannelSettings({
@@ -1151,6 +1172,22 @@ const EditChannelModal = (props) => {
       });
     });
 
+  const resolveStatusCodeRiskConfirm = (confirmed) => {
+    setStatusCodeRiskConfirmVisible(false);
+    setStatusCodeRiskDetailItems([]);
+    if (statusCodeRiskConfirmResolverRef.current) {
+      statusCodeRiskConfirmResolverRef.current(confirmed);
+      statusCodeRiskConfirmResolverRef.current = null;
+    }
+  };
+
+  const confirmStatusCodeRisk = (detailItems) =>
+    new Promise((resolve) => {
+      statusCodeRiskConfirmResolverRef.current = resolve;
+      setStatusCodeRiskDetailItems(detailItems);
+      setStatusCodeRiskConfirmVisible(true);
+    });
+
   const hasModelConfigChanged = (normalizedModels, modelMappingStr) => {
     if (!isEdit) return true;
     const initialModels = initialModelsRef.current;
@@ -1340,6 +1377,17 @@ const EditChannelModal = (props) => {
       }
     }
 
+    const riskyStatusCodeRedirects = collectNewDisallowedStatusCodeRedirects(
+      initialStatusCodeMappingRef.current,
+      localInputs.status_code_mapping,
+    );
+    if (riskyStatusCodeRedirects.length > 0) {
+      const confirmed = await confirmStatusCodeRisk(riskyStatusCodeRedirects);
+      if (!confirmed) {
+        return;
+      }
+    }
+
     if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
       localInputs.base_url = localInputs.base_url.slice(
         0,
@@ -3440,6 +3488,12 @@ const EditChannelModal = (props) => {
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
       </SideSheet>
+      <StatusCodeRiskGuardModal
+        visible={statusCodeRiskConfirmVisible}
+        detailItems={statusCodeRiskDetailItems}
+        onCancel={() => resolveStatusCodeRiskConfirm(false)}
+        onConfirm={() => resolveStatusCodeRiskConfirm(true)}
+      />
       {/* 使用通用安全验证模态框 */}
       <SecureVerificationModal
         visible={isModalVisible}

+ 41 - 0
web/src/components/table/channels/modals/StatusCodeRiskGuardModal.jsx

@@ -0,0 +1,41 @@
+import React, { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import RiskAcknowledgementModal from '../../../common/modals/RiskAcknowledgementModal';
+import {
+  STATUS_CODE_RISK_I18N_KEYS,
+  STATUS_CODE_RISK_CHECKLIST_KEYS,
+} from './statusCodeRiskGuard';
+
+const StatusCodeRiskGuardModal = React.memo(function StatusCodeRiskGuardModal({
+  visible,
+  detailItems,
+  onCancel,
+  onConfirm,
+}) {
+  const { t, i18n } = useTranslation();
+  const checklist = useMemo(
+    () => STATUS_CODE_RISK_CHECKLIST_KEYS.map((item) => t(item)),
+    [t, i18n.language],
+  );
+
+  return (
+    <RiskAcknowledgementModal
+      visible={visible}
+      title={t(STATUS_CODE_RISK_I18N_KEYS.title)}
+      markdownContent={t(STATUS_CODE_RISK_I18N_KEYS.markdown)}
+      detailTitle={t(STATUS_CODE_RISK_I18N_KEYS.detailTitle)}
+      detailItems={detailItems}
+      checklist={checklist}
+      inputPrompt={t(STATUS_CODE_RISK_I18N_KEYS.inputPrompt)}
+      requiredText={t(STATUS_CODE_RISK_I18N_KEYS.confirmText)}
+      inputPlaceholder={t(STATUS_CODE_RISK_I18N_KEYS.inputPlaceholder)}
+      mismatchText={t(STATUS_CODE_RISK_I18N_KEYS.mismatchText)}
+      cancelText={t('取消')}
+      confirmText={t(STATUS_CODE_RISK_I18N_KEYS.confirmButton)}
+      onCancel={onCancel}
+      onConfirm={onConfirm}
+    />
+  );
+});
+
+export default StatusCodeRiskGuardModal;

+ 101 - 0
web/src/components/table/channels/modals/statusCodeRiskGuard.js

@@ -0,0 +1,101 @@
+const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524]);
+
+export const STATUS_CODE_RISK_I18N_KEYS = {
+  title: '高危操作确认',
+  detailTitle: '检测到以下高危状态码重定向规则',
+  inputPrompt: '操作确认',
+  confirmButton: '我确认开启高危重试',
+  markdown: '高危状态码重试风险告知与免责声明Markdown',
+  confirmText: '高危状态码重试风险确认输入文本',
+  inputPlaceholder: '高危状态码重试风险输入框占位文案',
+  mismatchText: '高危状态码重试风险输入不匹配提示',
+};
+
+export const STATUS_CODE_RISK_CHECKLIST_KEYS = [
+  '高危状态码重试风险确认项1',
+  '高危状态码重试风险确认项2',
+  '高危状态码重试风险确认项3',
+  '高危状态码重试风险确认项4',
+];
+
+function parseStatusCodeKey(rawKey) {
+  if (typeof rawKey !== 'string') {
+    return null;
+  }
+  const normalized = rawKey.trim();
+  if (!/^[1-5]\d{2}$/.test(normalized)) {
+    return null;
+  }
+  return Number.parseInt(normalized, 10);
+}
+
+function parseStatusCodeMappingTarget(rawValue) {
+  if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
+    return rawValue >= 100 && rawValue <= 599 ? rawValue : null;
+  }
+  if (typeof rawValue === 'string') {
+    const normalized = rawValue.trim();
+    if (!/^[1-5]\d{2}$/.test(normalized)) {
+      return null;
+    }
+    const code = Number.parseInt(normalized, 10);
+    return code >= 100 && code <= 599 ? code : null;
+  }
+  return null;
+}
+
+export function collectDisallowedStatusCodeRedirects(statusCodeMappingStr) {
+  if (
+    typeof statusCodeMappingStr !== 'string' ||
+    statusCodeMappingStr.trim() === ''
+  ) {
+    return [];
+  }
+
+  let parsed;
+  try {
+    parsed = JSON.parse(statusCodeMappingStr);
+  } catch (error) {
+    return [];
+  }
+
+  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+    return [];
+  }
+
+  const riskyMappings = [];
+  Object.entries(parsed).forEach(([rawFrom, rawTo]) => {
+    const fromCode = parseStatusCodeKey(rawFrom);
+    const toCode = parseStatusCodeMappingTarget(rawTo);
+    if (fromCode === null || toCode === null) {
+      return;
+    }
+    if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) {
+      return;
+    }
+    if (fromCode === toCode) {
+      return;
+    }
+    riskyMappings.push(`${fromCode} -> ${toCode}`);
+  });
+
+  return Array.from(new Set(riskyMappings)).sort();
+}
+
+export function collectNewDisallowedStatusCodeRedirects(
+  originalStatusCodeMappingStr,
+  currentStatusCodeMappingStr,
+) {
+  const currentRisky = collectDisallowedStatusCodeRedirects(
+    currentStatusCodeMappingStr,
+  );
+  if (currentRisky.length === 0) {
+    return [];
+  }
+
+  const originalRiskySet = new Set(
+    collectDisallowedStatusCodeRedirects(originalStatusCodeMappingStr),
+  );
+
+  return currentRisky.filter((mapping) => !originalRiskySet.has(mapping));
+}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 5 - 0
web/src/i18n/locales/en.json


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 5 - 0
web/src/i18n/locales/zh-CN.json


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 5 - 0
web/src/i18n/locales/zh-TW.json


+ 1 - 1
web/src/pages/Setting/Operation/SettingsMonitoring.jsx

@@ -254,7 +254,7 @@ export default function SettingsMonitoring(props) {
                   label={t('自动重试状态码')}
                   placeholder={t('例如:401, 403, 429, 500-599')}
                   extraText={t(
-                    '支持填写单个状态码或范围(含首尾),使用逗号分隔',
+                    '支持填写单个状态码或范围(含首尾),使用逗号分隔;504 和 524 始终不重试,不受此处配置影响',
                   )}
                   field={'AutomaticRetryStatusCodes'}
                   onChange={(value) =>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác