diff --git a/controller/channel.go b/controller/channel.go index d9e4d422..a2ee5743 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1030,3 +1030,261 @@ func CopyChannel(c *gin.Context) { // success c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}}) } + +// MultiKeyManageRequest represents the request for multi-key management operations +type MultiKeyManageRequest struct { + ChannelId int `json:"channel_id"` + Action string `json:"action"` // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status" + KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions +} + +// MultiKeyStatusResponse represents the response for key status query +type MultiKeyStatusResponse struct { + Keys []KeyStatus `json:"keys"` +} + +type KeyStatus struct { + Index int `json:"index"` + Status int `json:"status"` // 1: enabled, 2: disabled + DisabledTime int64 `json:"disabled_time,omitempty"` + Reason string `json:"reason,omitempty"` + KeyPreview string `json:"key_preview"` // first 10 chars of key for identification +} + +// ManageMultiKeys handles multi-key management operations +func ManageMultiKeys(c *gin.Context) { + request := MultiKeyManageRequest{} + err := c.ShouldBindJSON(&request) + if err != nil { + common.ApiError(c, err) + return + } + + channel, err := model.GetChannelById(request.ChannelId, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "渠道不存在", + }) + return + } + + if !channel.ChannelInfo.IsMultiKey { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该渠道不是多密钥模式", + }) + return + } + + switch request.Action { + case "get_key_status": + keys := channel.GetKeys() + var keyStatusList []KeyStatus + + for i, key := range keys { + status := 1 // default enabled + var disabledTime int64 + var reason string + + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + if status != 1 { + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i] + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + reason = channel.ChannelInfo.MultiKeyDisabledReason[i] + } + } + + // Create key preview (first 10 chars) + keyPreview := key + if len(key) > 10 { + keyPreview = key[:10] + "..." + } + + keyStatusList = append(keyStatusList, KeyStatus{ + Index: i, + Status: status, + DisabledTime: disabledTime, + Reason: reason, + KeyPreview: keyPreview, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": MultiKeyStatusResponse{Keys: keyStatusList}, + }) + return + + case "disable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要禁用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + + channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled + channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp() + channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = "手动禁用" + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已禁用", + }) + return + + case "enable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要启用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + // 从状态列表中删除该密钥的记录,使其回到默认启用状态 + if channel.ChannelInfo.MultiKeyStatusList != nil { + delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex) + } + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已启用", + }) + return + + case "delete_disabled_keys": + keys := channel.GetKeys() + var remainingKeys []string + var deletedCount int + var newStatusList = make(map[int]int) + var newDisabledTime = make(map[int]int64) + var newDisabledReason = make(map[int]string) + + newIndex := 0 + for i, key := range keys { + status := 1 // default enabled + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + // 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥 + if status == 3 { + deletedCount++ + } else { + remainingKeys = append(remainingKeys, key) + // 保留非自动禁用密钥的状态信息,重新索引 + if status != 1 { + newStatusList[newIndex] = status + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { + newDisabledTime[newIndex] = t + } + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { + newDisabledReason[newIndex] = r + } + } + } + newIndex++ + } + } + + if deletedCount == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "没有需要删除的自动禁用密钥", + }) + return + } + + // Update channel with remaining keys + channel.Key = strings.Join(remainingKeys, "\n") + channel.ChannelInfo.MultiKeySize = len(remainingKeys) + channel.ChannelInfo.MultiKeyStatusList = newStatusList + channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime + channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount), + "data": deletedCount, + }) + return + + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不支持的操作", + }) + return + } +} diff --git a/model/channel.go b/model/channel.go index bcffc102..502171fa 100644 --- a/model/channel.go +++ b/model/channel.go @@ -41,6 +41,7 @@ type Channel struct { Priority *int64 `json:"priority" gorm:"bigint;default:0"` AutoBan *int `json:"auto_ban" gorm:"default:1"` OtherInfo string `json:"other_info"` + Settings string `json:"settings"` Tag *string `json:"tag" gorm:"index"` Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 ParamOverride *string `json:"param_override" gorm:"type:text"` @@ -52,11 +53,13 @@ type Channel struct { } type ChannelInfo struct { - IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 - MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 - MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status - MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 - MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` + IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 + MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 + MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status + MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason"` // key禁用原因列表,key index -> reason + MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time"` // key禁用时间列表,key index -> time + MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 + MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` } // Value implements driver.Valuer interface @@ -70,7 +73,7 @@ func (c *ChannelInfo) Scan(value interface{}) error { return common.Unmarshal(bytesValue, c) } -func (channel *Channel) getKeys() []string { +func (channel *Channel) GetKeys() []string { if channel.Key == "" { return []string{} } @@ -101,7 +104,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { } // Obtain all keys (split by \n) - keys := channel.getKeys() + keys := channel.GetKeys() if len(keys) == 0 { // No keys available, return error, should disable the channel return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey) @@ -528,8 +531,8 @@ func CleanupChannelPollingLocks() { }) } -func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) { - keys := channel.getKeys() +func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) { + keys := channel.GetKeys() if len(keys) == 0 { channel.Status = status } else { @@ -547,6 +550,14 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) { delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) } else { channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason + channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp() } if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize { channel.Status = common.ChannelStatusAutoDisabled @@ -569,7 +580,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri } if channelCache.ChannelInfo.IsMultiKey { // 如果是多Key模式,更新缓存中的状态 - handlerMultiKeyUpdate(channelCache, usingKey, status) + handlerMultiKeyUpdate(channelCache, usingKey, status, reason) //CacheUpdateChannel(channelCache) //return true } else { @@ -600,7 +611,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri if channel.ChannelInfo.IsMultiKey { beforeStatus := channel.Status - handlerMultiKeyUpdate(channel, usingKey, status) + handlerMultiKeyUpdate(channel, usingKey, status, reason) if beforeStatus != channel.Status { shouldUpdateAbilities = true } diff --git a/model/channel_cache.go b/model/channel_cache.go index ecd87607..6ca23cf9 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -70,7 +70,7 @@ func InitChannelCache() { //channelsIDM = newChannelId2channel for i, channel := range newChannelId2channel { if channel.ChannelInfo.IsMultiKey { - channel.Keys = channel.getKeys() + channel.Keys = channel.GetKeys() if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { if oldChannel, ok := channelsIDM[i]; ok { // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 diff --git a/router/api-router.go b/router/api-router.go index bc49803a..12846012 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -120,6 +120,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) channelRoute.GET("/tag/models", controller.GetTagModels) channelRoute.POST("/copy/:id", controller.CopyChannel) + channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys) } tokenRoute := apiRouter.Group("/token") tokenRoute.Use(middleware.UserAuth()) diff --git a/web/src/components/table/channels/ChannelsColumnDefs.js b/web/src/components/table/channels/ChannelsColumnDefs.js index beb5fe55..18cb5700 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.js +++ b/web/src/components/table/channels/ChannelsColumnDefs.js @@ -210,7 +210,9 @@ export const getChannelsColumns = ({ copySelectedChannel, refresh, activePage, - channels + channels, + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel }) => { return [ { @@ -503,47 +505,7 @@ export const getChannelsColumns = ({ /> - {record.channel_info?.is_multi_key ? ( - - { - record.status === 1 ? ( - - ) : ( - - ) - } - manageChannel(record.id, 'enable_all', record), - } - ]} - > - + {record.channel_info?.is_multi_key ? ( + + + { + setCurrentMultiKeyChannel(record); + setShowMultiKeyManageModal(true); + }, + } + ]} + > + + )} { setEditingTag, copySelectedChannel, refresh, + // Multi-key management + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel, } = channelsData; // Get all columns @@ -79,6 +82,8 @@ const ChannelsTable = (channelsData) => { refresh, activePage, channels, + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel, }); }, [ t, @@ -98,6 +103,8 @@ const ChannelsTable = (channelsData) => { refresh, activePage, channels, + setShowMultiKeyManageModal, + setCurrentMultiKeyChannel, ]); // Filter columns based on visibility settings diff --git a/web/src/components/table/channels/index.jsx b/web/src/components/table/channels/index.jsx index b0106b4e..66e2d72d 100644 --- a/web/src/components/table/channels/index.jsx +++ b/web/src/components/table/channels/index.jsx @@ -30,6 +30,7 @@ import ModelTestModal from './modals/ModelTestModal.jsx'; import ColumnSelectorModal from './modals/ColumnSelectorModal.jsx'; import EditChannelModal from './modals/EditChannelModal.jsx'; import EditTagModal from './modals/EditTagModal.jsx'; +import MultiKeyManageModal from './modals/MultiKeyManageModal.jsx'; import { createCardProPagination } from '../../../helpers/utils'; const ChannelsPage = () => { @@ -54,6 +55,12 @@ const ChannelsPage = () => { /> + channelsData.setShowMultiKeyManageModal(false)} + channel={channelsData.currentMultiKeyChannel} + onRefresh={channelsData.refresh} + /> {/* Main Content */} . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Modal, + Button, + Table, + Tag, + Typography, + Space, + Tooltip, + Popconfirm, + Empty, + Spin, + Banner +} from '@douyinfe/semi-ui'; +import { + IconRefresh, + IconDelete, + IconClose, + IconSave, + IconSetting +} from '@douyinfe/semi-icons'; +import { API, showError, showSuccess, timestamp2string } from '../../../../helpers/index.js'; + +const { Text, Title } = Typography; + +const MultiKeyManageModal = ({ + visible, + onCancel, + channel, + onRefresh +}) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [keyStatusList, setKeyStatusList] = useState([]); + const [operationLoading, setOperationLoading] = useState({}); + + // Load key status data + const loadKeyStatus = async () => { + if (!channel?.id) return; + + setLoading(true); + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'get_key_status' + }); + + if (res.data.success) { + setKeyStatusList(res.data.data.keys || []); + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('获取密钥状态失败')); + } finally { + setLoading(false); + } + }; + + // Disable a specific key + const handleDisableKey = async (keyIndex) => { + const operationId = `disable_${keyIndex}`; + setOperationLoading(prev => ({ ...prev, [operationId]: true })); + + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'disable_key', + key_index: keyIndex + }); + + if (res.data.success) { + showSuccess(t('密钥已禁用')); + await loadKeyStatus(); // Reload data + onRefresh && onRefresh(); // Refresh parent component + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('禁用密钥失败')); + } finally { + setOperationLoading(prev => ({ ...prev, [operationId]: false })); + } + }; + + // Enable a specific key + const handleEnableKey = async (keyIndex) => { + const operationId = `enable_${keyIndex}`; + setOperationLoading(prev => ({ ...prev, [operationId]: true })); + + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'enable_key', + key_index: keyIndex + }); + + if (res.data.success) { + showSuccess(t('密钥已启用')); + await loadKeyStatus(); // Reload data + onRefresh && onRefresh(); // Refresh parent component + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('启用密钥失败')); + } finally { + setOperationLoading(prev => ({ ...prev, [operationId]: false })); + } + }; + + // Delete all disabled keys + const handleDeleteDisabledKeys = async () => { + setOperationLoading(prev => ({ ...prev, delete_disabled: true })); + + try { + const res = await API.post('/api/channel/multi_key/manage', { + channel_id: channel.id, + action: 'delete_disabled_keys' + }); + + if (res.data.success) { + showSuccess(res.data.message); + await loadKeyStatus(); // Reload data + onRefresh && onRefresh(); // Refresh parent component + } else { + showError(res.data.message); + } + } catch (error) { + showError(t('删除禁用密钥失败')); + } finally { + setOperationLoading(prev => ({ ...prev, delete_disabled: false })); + } + }; + + // Effect to load data when modal opens + useEffect(() => { + if (visible && channel?.id) { + loadKeyStatus(); + } + }, [visible, channel?.id]); + + // Get status tag component + const renderStatusTag = (status) => { + switch (status) { + case 1: + return {t('已启用')}; + case 2: + return {t('已禁用')}; + case 3: + return {t('自动禁用')}; + default: + return {t('未知状态')}; + } + }; + + // Table columns definition + const columns = [ + { + title: t('索引'), + dataIndex: 'index', + render: (text) => `#${text}`, + }, + { + title: t('密钥预览'), + dataIndex: 'key_preview', + render: (text) => ( + + {text} + + ), + }, + { + title: t('状态'), + dataIndex: 'status', + width: 100, + render: (status) => renderStatusTag(status), + }, + { + title: t('禁用原因'), + dataIndex: 'reason', + width: 220, + render: (reason, record) => { + if (record.status === 1 || !reason) { + return -; + } + return ( + + + {reason} + + + ); + }, + }, + { + title: t('禁用时间'), + dataIndex: 'disabled_time', + width: 150, + render: (time, record) => { + if (record.status === 1 || !time) { + return -; + } + return ( + + + {timestamp2string(time)} + + + ); + }, + }, + { + title: t('操作'), + key: 'action', + width: 120, + render: (_, record) => ( + + {record.status === 1 ? ( + handleDisableKey(record.index)} + > + + + ) : ( + handleEnableKey(record.index)} + > + + + )} + + ), + }, + ]; + + // Calculate statistics + const enabledCount = keyStatusList.filter(key => key.status === 1).length; + const manualDisabledCount = keyStatusList.filter(key => key.status === 2).length; + const autoDisabledCount = keyStatusList.filter(key => key.status === 3).length; + const totalCount = keyStatusList.length; + + return ( + + + {t('多密钥管理')} - {channel?.name} + + } + visible={visible} + onCancel={onCancel} + width={800} + height={600} + footer={ + + + + {autoDisabledCount > 0 && ( + + + + )} + + } + > +
+ {/* Statistics Banner */} + + + {t('总共 {{total}} 个密钥,{{enabled}} 个已启用,{{manual}} 个手动禁用,{{auto}} 个自动禁用', { + total: totalCount, + enabled: enabledCount, + manual: manualDisabledCount, + auto: autoDisabledCount + })} + + {channel?.channel_info?.multi_key_mode && ( +
+ + {t('多密钥模式')}: {channel.channel_info.multi_key_mode === 'random' ? t('随机') : t('轮询')} + +
+ )} +
+ } + /> + + {/* Key Status Table */} + + {keyStatusList.length > 0 ? ( + + ) : ( + !loading && ( + + ) + )} + + + + ); +}; + +export default MultiKeyManageModal; \ No newline at end of file diff --git a/web/src/hooks/channels/useChannelsData.js b/web/src/hooks/channels/useChannelsData.js index d188c9fe..8f1f8c29 100644 --- a/web/src/hooks/channels/useChannelsData.js +++ b/web/src/hooks/channels/useChannelsData.js @@ -83,6 +83,10 @@ export const useChannelsData = () => { const [isProcessingQueue, setIsProcessingQueue] = useState(false); const [modelTablePage, setModelTablePage] = useState(1); + // Multi-key management states + const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false); + const [currentMultiKeyChannel, setCurrentMultiKeyChannel] = useState(null); + // Refs const requestCounter = useRef(0); const allSelectingRef = useRef(false); @@ -885,6 +889,12 @@ export const useChannelsData = () => { setModelTablePage, allSelectingRef, + // Multi-key management states + showMultiKeyManageModal, + setShowMultiKeyManageModal, + currentMultiKeyChannel, + setCurrentMultiKeyChannel, + // Form formApi, setFormApi,