Преглед изворни кода

🐛 fix: multi-key channel sync and Vertex-AI key-upload edge cases

Backend
1. controller/channel.go
   • Always hydrate `ChannelInfo` from DB in `UpdateChannel`, keeping `IsMultiKey` true so `MultiKeySize` is recalculated.

2. model/channel.go
   • getKeys(): accept both newline-separated keys and JSON array (`[ {...}, {...} ]`).
   • Update(): reuse new parser-logic to recalc `MultiKeySize`; prune stale indices in `MultiKeyStatusList`.

Frontend
1. pages/Channel/EditChannel.js
   • `handleVertexUploadChange`
     – Reset `vertexErroredNames` on every change so the “ignored files” prompt always re-appears.
     – In single-key mode keep only the last file; in batch mode keep all valid files.
     – Parse files, display “以下文件解析失败,已忽略:…”.
   • Batch-toggle checkbox
     – When switching from batch→single while multiple files are present, show a confirm dialog and retain only the first file (synchronises state, form and local caches).
   • On opening the “new channel” side-sheet, clear `vertexErroredNames` to restore error prompts.

Result
• “已启用 x/x” count updates immediately after editing multi-key channels.
• Vertex-AI key upload works intuitively: proper error feedback, no duplicated files, and safe down-switch from batch to single mode.
t0ng7u пре 5 месеци
родитељ
комит
06ad5e3f8c

+ 15 - 11
controller/channel.go

@@ -709,18 +709,22 @@ func UpdateChannel(c *gin.Context) {
 			}
 		}
 	}
+	// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
+	originChannel, err := model.GetChannelById(channel.Id, false)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	// Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained.
+	channel.ChannelInfo = originChannel.ChannelInfo
+
+	// If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info.
 	if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
-		originChannel, err := model.GetChannelById(channel.Id, false)
-		if err != nil {
-			c.JSON(http.StatusOK, gin.H{
-				"success": false,
-				"message": err.Error(),
-			})
-		}
-		if originChannel.ChannelInfo.IsMultiKey {
-			channel.ChannelInfo = originChannel.ChannelInfo
-			channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
-		}
+		channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
 	}
 	err = channel.Update()
 	if err != nil {

+ 30 - 5
model/channel.go

@@ -71,7 +71,19 @@ func (channel *Channel) getKeys() []string {
 	if channel.Key == "" {
 		return []string{}
 	}
-	// use \n to split keys
+	trimmed := strings.TrimSpace(channel.Key)
+	// If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios)
+	if strings.HasPrefix(trimmed, "[") {
+		var arr []json.RawMessage
+		if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
+			res := make([]string, len(arr))
+			for i, v := range arr {
+				res[i] = string(v)
+			}
+			return res
+		}
+	}
+	// Otherwise, fall back to splitting by newline
 	keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n")
 	return keys
 }
@@ -396,23 +408,36 @@ func (channel *Channel) Insert() error {
 }
 
 func (channel *Channel) Update() error {
-	// 如果是多密钥渠道,则根据当前 key 列表重新计算 MultiKeySize,避免编辑密钥后数量未同步
+	// If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys
 	if channel.ChannelInfo.IsMultiKey {
 		var keyStr string
 		if channel.Key != "" {
 			keyStr = channel.Key
 		} else {
-			// 如果当前未提供 key,读取数据库中的现有 key
+			// If key is not provided, read the existing key from the database
 			if existing, err := GetChannelById(channel.Id, true); err == nil {
 				keyStr = existing.Key
 			}
 		}
+		// Parse the key list (supports newline separation or JSON array)
 		keys := []string{}
 		if keyStr != "" {
-			keys = strings.Split(strings.Trim(keyStr, "\n"), "\n")
+			trimmed := strings.TrimSpace(keyStr)
+			if strings.HasPrefix(trimmed, "[") {
+				var arr []json.RawMessage
+				if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
+					keys = make([]string, len(arr))
+					for i, v := range arr {
+						keys[i] = string(v)
+					}
+				}
+			}
+			if len(keys) == 0 { // fallback to newline split
+				keys = strings.Split(strings.Trim(keyStr, "\n"), "\n")
+			}
 		}
 		channel.ChannelInfo.MultiKeySize = len(keys)
-		// 清理超过新 key 数量范围的状态数据,防止索引越界
+		// Clean up status data that exceeds the new key count to prevent index out of range
 		if channel.ChannelInfo.MultiKeyStatusList != nil {
 			for idx := range channel.ChannelInfo.MultiKeyStatusList {
 				if idx >= channel.ChannelInfo.MultiKeySize {

+ 0 - 1
web/src/components/table/ChannelsTable.js

@@ -987,7 +987,6 @@ const ChannelsTable = () => {
   };
 
   useEffect(() => {
-    // console.log('default effect')
     const localIdSort = localStorage.getItem('id-sort') === 'true';
     const localPageSize =
       parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;

+ 6 - 1
web/src/i18n/locales/en.json

@@ -1770,10 +1770,15 @@
   "轮询": "Polling",
   "密钥文件 (.json)": "Key file (.json)",
   "点击上传文件或拖拽文件到这里": "Click to upload file or drag and drop file here",
+  "仅支持 JSON 文件": "Only JSON files are supported",
   "仅支持 JSON 文件,支持多文件": "Only JSON files are supported, multiple files are supported",
   "请上传密钥文件": "Please upload the key file",
   "请填写部署地区": "Please fill in the deployment region",
   "请输入部署地区,例如:us-central1\n支持使用模型映射格式\n{\n    \"default\": \"us-central1\",\n    \"claude-3-5-sonnet-20240620\": \"europe-west1\"\n}": "Please enter the deployment region, for example: us-central1\nSupports using model mapping format\n{\n    \"default\": \"us-central1\",\n    \"claude-3-5-sonnet-20240620\": \"europe-west1\"\n}",
   "其他": "Other",
-  "未知渠道": "Unknown channel"
+  "未知渠道": "Unknown channel",
+  "切换为单密钥模式": "Switch to single key mode",
+  "将仅保留第一个密钥文件,其余文件将被移除,是否继续?": "Only the first key file will be retained, and the remaining files will be removed. Continue?",
+  "自定义模型名称": "Custom model name",
+  "启用全部密钥": "Enable all keys"
 }

+ 49 - 11
web/src/pages/Channel/EditChannel.js

@@ -26,7 +26,6 @@ import {
   Form,
   Row,
   Col,
-  Upload,
 } from '@douyinfe/semi-ui';
 import { getChannelModels, copy, getChannelIcon, getModelCategories } from '../../helpers';
 import {
@@ -424,9 +423,10 @@ const EditChannel = (props) => {
   }, [props.visible, channelId]);
 
   const handleVertexUploadChange = ({ fileList }) => {
+    vertexErroredNames.current.clear();
     (async () => {
-      const validFiles = [];
-      const keys = [];
+      let validFiles = [];
+      let keys = [];
       const errorNames = [];
       for (const item of fileList) {
         const fileObj = item.fileInstance;
@@ -434,7 +434,7 @@ const EditChannel = (props) => {
         try {
           const txt = await fileObj.text();
           keys.push(JSON.parse(txt));
-          validFiles.push(item); // 仅合法文件加入列表
+          validFiles.push(item);
         } catch (err) {
           if (!vertexErroredNames.current.has(item.name)) {
             errorNames.push(item.name);
@@ -443,6 +443,12 @@ const EditChannel = (props) => {
         }
       }
 
+      // 非批量模式下只保留一个文件(最新选择的),避免重复叠加
+      if (!batch && validFiles.length > 1) {
+        validFiles = [validFiles[validFiles.length - 1]];
+        keys = [keys[keys.length - 1]];
+      }
+
       setVertexKeys(keys);
       setVertexFileList(validFiles);
       if (formApiRef.current) {
@@ -603,13 +609,45 @@ const EditChannel = (props) => {
   const batchAllowed = !isEdit || isMultiKeyChannel;
   const batchExtra = batchAllowed ? (
     <Space>
-      <Checkbox disabled={isEdit} checked={batch} onChange={() => {
-        setBatch(!batch);
-        if (batch) {
-          setMultiToSingle(false);
-          setMultiKeyMode('random');
-        }
-      }}>{t('批量创建')}</Checkbox>
+      <Checkbox
+        disabled={isEdit}
+        checked={batch}
+        onChange={(e) => {
+          const checked = e.target.checked;
+
+          if (!checked && vertexFileList.length > 1) {
+            Modal.confirm({
+              title: t('切换为单密钥模式'),
+              content: t('将仅保留第一个密钥文件,其余文件将被移除,是否继续?'),
+              onOk: () => {
+                const firstFile = vertexFileList[0];
+                const firstKey = vertexKeys[0] ? [vertexKeys[0]] : [];
+
+                setVertexFileList([firstFile]);
+                setVertexKeys(firstKey);
+
+                formApiRef.current?.setValue('vertex_files', [firstFile]);
+                setInputs((prev) => ({ ...prev, vertex_files: [firstFile] }));
+
+                setBatch(false);
+                setMultiToSingle(false);
+                setMultiKeyMode('random');
+              },
+              onCancel: () => {
+                setBatch(true);
+              },
+              centered: true,
+            });
+            return;
+          }
+
+          setBatch(checked);
+          if (!checked) {
+            setMultiToSingle(false);
+            setMultiKeyMode('random');
+          }
+        }}
+      >{t('批量创建')}</Checkbox>
       {batch && (
         <Checkbox disabled={isEdit} checked={multiToSingle} onChange={() => {
           setMultiToSingle(prev => !prev);