From 332d5a62f201988fb36df0c73359c624d064f292 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 30 Apr 2026 12:53:00 +0800 Subject: [PATCH] feat: support codex api key credentials --- common/codex_credential.go | 58 +++++++++ controller/channel.go | 39 +++--- controller/codex_channel_test.go | 111 ++++++++++++++++++ controller/codex_usage.go | 18 ++- service/codex_credential_refresh.go | 26 +--- service/codex_credential_refresh_task.go | 2 +- .../channels/modals/EditChannelModal.jsx | 69 ++++++++++- web/src/constants/channel.constants.js | 2 +- web/src/helpers/codex.js | 31 +++++ web/src/hooks/channels/useChannelsData.jsx | 3 +- web/src/i18n/locales/en.json | 8 +- web/src/i18n/locales/zh-CN.json | 8 +- 12 files changed, 316 insertions(+), 59 deletions(-) create mode 100644 common/codex_credential.go create mode 100644 controller/codex_channel_test.go create mode 100644 web/src/helpers/codex.js diff --git a/common/codex_credential.go b/common/codex_credential.go new file mode 100644 index 0000000..a5938ea --- /dev/null +++ b/common/codex_credential.go @@ -0,0 +1,58 @@ +package common + +import ( + "errors" + "strings" +) + +type CodexCredentialMode string + +const ( + CodexCredentialModeAPIKey CodexCredentialMode = "api_key" + CodexCredentialModeOAuth CodexCredentialMode = "oauth" +) + +var ( + ErrCodexOAuthCredentialRequired = errors.New("codex channel: oauth credential required") + ErrCodexOAuthCredentialInvalidJSON = errors.New("codex channel: invalid oauth key json") + ErrCodexOAuthAccessTokenRequired = errors.New("codex channel: access_token is required") + ErrCodexOAuthAccountIDRequired = errors.New("codex channel: account_id is required") +) + +type CodexOAuthCredential struct { + IDToken string `json:"id_token,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + AccountID string `json:"account_id,omitempty"` + LastRefresh string `json:"last_refresh,omitempty"` + Email string `json:"email,omitempty"` + Type string `json:"type,omitempty"` + Expired string `json:"expired,omitempty"` +} + +func ParseCodexOAuthCredential(raw string) (*CodexOAuthCredential, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || !strings.HasPrefix(trimmed, "{") { + return nil, ErrCodexOAuthCredentialRequired + } + + var credential CodexOAuthCredential + if err := Unmarshal([]byte(trimmed), &credential); err != nil { + return nil, ErrCodexOAuthCredentialInvalidJSON + } + if strings.TrimSpace(credential.AccessToken) == "" { + return nil, ErrCodexOAuthAccessTokenRequired + } + if strings.TrimSpace(credential.AccountID) == "" { + return nil, ErrCodexOAuthAccountIDRequired + } + + return &credential, nil +} + +func DetectCodexCredentialMode(raw string) CodexCredentialMode { + if _, err := ParseCodexOAuthCredential(raw); err == nil { + return CodexCredentialModeOAuth + } + return CodexCredentialModeAPIKey +} diff --git a/controller/channel.go b/controller/channel.go index f9a0d0c..5d4b939 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -3,6 +3,7 @@ package controller import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -615,20 +616,22 @@ func validateChannel(channel *model.Channel, isAdd bool) error { // Codex OAuth key validation (optional, only when JSON object is provided) if channel.Type == constant.ChannelTypeCodex { trimmedKey := strings.TrimSpace(channel.Key) - if isAdd || trimmedKey != "" { - if !strings.HasPrefix(trimmedKey, "{") { - return fmt.Errorf("Codex key must be a valid JSON object") - } - var keyMap map[string]any - if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil { + if isAdd && trimmedKey == "" { + return fmt.Errorf("Codex key cannot be empty") + } + if strings.HasPrefix(trimmedKey, "{") { + if _, err := common.ParseCodexOAuthCredential(trimmedKey); err != nil { + if errors.Is(err, common.ErrCodexOAuthCredentialInvalidJSON) { + return fmt.Errorf("Codex key must be a valid JSON object") + } + if errors.Is(err, common.ErrCodexOAuthAccessTokenRequired) { + return fmt.Errorf("Codex key JSON must include access_token") + } + if errors.Is(err, common.ErrCodexOAuthAccountIDRequired) { + return fmt.Errorf("Codex key JSON must include account_id") + } return fmt.Errorf("Codex key must be a valid JSON object") } - if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { - return fmt.Errorf("Codex key JSON must include access_token") - } - if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" { - return fmt.Errorf("Codex key JSON must include account_id") - } } } @@ -647,6 +650,10 @@ func RefreshCodexChannelCredential(c *gin.Context) { oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true}) if err != nil { + if errors.Is(err, common.ErrCodexOAuthCredentialRequired) { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前凭证方式不支持刷新凭证"}) + return + } common.SysError("failed to refresh codex channel credential: " + err.Error()) c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"}) return @@ -2112,11 +2119,11 @@ func GetUserChannelsForBinding(c *gin.Context) { result := make([]gin.H, 0, len(channels)) for _, ch := range channels { result = append(result, gin.H{ - "id": ch.Id, - "name": ch.Name, + "id": ch.Id, + "name": ch.Name, "public_name": ch.PublicName, - "type": ch.Type, - "remark": ch.Remark, + "type": ch.Type, + "remark": ch.Remark, }) } diff --git a/controller/codex_channel_test.go b/controller/codex_channel_test.go new file mode 100644 index 0000000..2071c5b --- /dev/null +++ b/controller/codex_channel_test.go @@ -0,0 +1,111 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupCodexChannelDB(t *testing.T, key string) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + + origDB := model.DB + origLogDB := model.LOG_DB + model.DB = db + model.LOG_DB = db + common.UsingSQLite = true + common.RedisEnabled = false + + require.NoError(t, db.AutoMigrate(&model.Channel{})) + require.NoError(t, db.Create(&model.Channel{ + Id: 1, + Name: "codex-channel", + PublicName: "codex-channel", + Type: constant.ChannelTypeCodex, + Key: key, + Status: common.ChannelStatusEnabled, + }).Error) + + t.Cleanup(func() { + model.DB = origDB + model.LOG_DB = origLogDB + _ = sqlDB.Close() + }) + + return db +} + +func setupCodexChannelRouter(t *testing.T, key string) *gin.Engine { + t.Helper() + setupCodexChannelDB(t, key) + + gin.SetMode(gin.TestMode) + r := gin.New() + g := r.Group("/api/channel") + g.POST("/:id/codex/refresh", RefreshCodexChannelCredential) + g.GET("/:id/codex/usage", GetCodexChannelUsage) + return r +} + +func TestValidateChannelAcceptsCodexAPIKey(t *testing.T) { + channel := &model.Channel{ + Name: "codex-api-key", + PublicName: "codex-api-key", + Type: constant.ChannelTypeCodex, + Key: "sk-codex-api-key", + } + + require.NoError(t, validateChannel(channel, true)) +} + +func TestValidateChannelRejectsCodexOAuthWithoutAccountID(t *testing.T) { + channel := &model.Channel{ + Name: "codex-oauth", + PublicName: "codex-oauth", + Type: constant.ChannelTypeCodex, + Key: `{"access_token":"token-only"}`, + } + + err := validateChannel(channel, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "account_id") +} + +func TestRefreshCodexChannelCredentialRejectsAPIKeyMode(t *testing.T) { + router := setupCodexChannelRouter(t, "sk-codex-api-key") + + req := httptest.NewRequest(http.MethodPost, "/api/channel/1/codex/refresh", bytes.NewReader([]byte(`{}`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "当前凭证方式不支持刷新凭证") +} + +func TestGetCodexChannelUsageRejectsAPIKeyMode(t *testing.T) { + router := setupCodexChannelRouter(t, "sk-codex-api-key") + + req := httptest.NewRequest(http.MethodGet, "/api/channel/1/codex/usage", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "当前凭证方式不支持查看用量") +} diff --git a/controller/codex_usage.go b/controller/codex_usage.go index 52fdbdf..98dc465 100644 --- a/controller/codex_usage.go +++ b/controller/codex_usage.go @@ -2,6 +2,7 @@ package controller import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -11,7 +12,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" - "github.com/QuantumNous/new-api/relay/channel/codex" "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" @@ -42,22 +42,19 @@ func GetCodexChannelUsage(c *gin.Context) { return } - oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key)) + oauthKey, err := common.ParseCodexOAuthCredential(strings.TrimSpace(ch.Key)) if err != nil { + if errors.Is(err, common.ErrCodexOAuthCredentialRequired) { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前凭证方式不支持查看用量"}) + return + } common.SysError("failed to parse oauth key: " + err.Error()) c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"}) return } + accessToken := strings.TrimSpace(oauthKey.AccessToken) accountID := strings.TrimSpace(oauthKey.AccountID) - if accessToken == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: access_token is required"}) - return - } - if accountID == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: account_id is required"}) - return - } client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy) if err != nil { @@ -98,6 +95,7 @@ func GetCodexChannelUsage(c *gin.Context) { ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second) defer cancel2() + statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID) if err != nil { common.SysError("failed to fetch codex usage after refresh: " + err.Error()) diff --git a/service/codex_credential_refresh.go b/service/codex_credential_refresh.go index 2e681ee..8193eb2 100644 --- a/service/codex_credential_refresh.go +++ b/service/codex_credential_refresh.go @@ -2,7 +2,6 @@ package service import ( "context" - "errors" "fmt" "strings" "time" @@ -16,28 +15,7 @@ type CodexCredentialRefreshOptions struct { ResetCaches bool } -type CodexOAuthKey struct { - IDToken string `json:"id_token,omitempty"` - AccessToken string `json:"access_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - - AccountID string `json:"account_id,omitempty"` - LastRefresh string `json:"last_refresh,omitempty"` - Email string `json:"email,omitempty"` - Type string `json:"type,omitempty"` - Expired string `json:"expired,omitempty"` -} - -func parseCodexOAuthKey(raw string) (*CodexOAuthKey, error) { - if strings.TrimSpace(raw) == "" { - return nil, errors.New("codex channel: empty oauth key") - } - var key CodexOAuthKey - if err := common.Unmarshal([]byte(raw), &key); err != nil { - return nil, errors.New("codex channel: invalid oauth key json") - } - return &key, nil -} +type CodexOAuthKey = common.CodexOAuthCredential func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts CodexCredentialRefreshOptions) (*CodexOAuthKey, *model.Channel, error) { ch, err := model.GetChannelById(channelID, true) @@ -51,7 +29,7 @@ func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts Code return nil, nil, fmt.Errorf("channel type is not Codex") } - oauthKey, err := parseCodexOAuthKey(strings.TrimSpace(ch.Key)) + oauthKey, err := common.ParseCodexOAuthCredential(strings.TrimSpace(ch.Key)) if err != nil { return nil, nil, err } diff --git a/service/codex_credential_refresh_task.go b/service/codex_credential_refresh_task.go index 627ab92..aa1806a 100644 --- a/service/codex_credential_refresh_task.go +++ b/service/codex_credential_refresh_task.go @@ -93,7 +93,7 @@ func runCodexCredentialAutoRefreshOnce() { continue } - oauthKey, err := parseCodexOAuthKey(rawKey) + oauthKey, err := common.ParseCodexOAuthCredential(rawKey) if err != nil { continue } diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 1b7a0ab..de16b09 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -26,6 +26,10 @@ import { showSuccess, verifyJSON, } from '../../../../helpers'; +import { + CODEX_CREDENTIAL_MODE, + detectCodexCredentialMode, +} from '../../../../helpers/codex'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; import { CHANNEL_OPTIONS } from '../../../../constants'; import { @@ -235,8 +239,14 @@ const EditChannelModal = (props) => { const [isIonetChannel, setIsIonetChannel] = useState(false); const [ionetMetadata, setIonetMetadata] = useState(null); const [codexOAuthModalVisible, setCodexOAuthModalVisible] = useState(false); + const [codexCredentialMode, setCodexCredentialMode] = useState( + CODEX_CREDENTIAL_MODE.API_KEY, + ); const [codexCredentialRefreshing, setCodexCredentialRefreshing] = useState(false); + const isCodexOAuthMode = + inputs.type === 57 && + codexCredentialMode === CODEX_CREDENTIAL_MODE.OAUTH; // 密钥显示状态 const [keyDisplayState, setKeyDisplayState] = useState({ @@ -537,6 +547,7 @@ const EditChannelModal = (props) => { setUseManualInput(false); if (value === 57) { + setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); setBatch(false); setMultiToSingle(false); setMultiKeyMode('random'); @@ -546,6 +557,8 @@ const EditChannelModal = (props) => { formApiRef.current.setValue('vertex_files', []); } setInputs((prev) => ({ ...prev, vertex_files: [] })); + } else { + setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); } } //setAutoBan @@ -689,6 +702,12 @@ const EditChannelModal = (props) => { data.base_url = 'https://ark.cn-beijing.volces.com'; } + if (data.type === 57) { + setCodexCredentialMode(detectCodexCredentialMode(data.key)); + } else { + setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); + } + setInputs(data); if (formApiRef.current) { formApiRef.current.setValues(data); @@ -922,6 +941,7 @@ const EditChannelModal = (props) => { }; const handleCodexOAuthGenerated = (key) => { + setCodexCredentialMode(CODEX_CREDENTIAL_MODE.OAUTH); handleInputChange('key', key); formatJsonField('key'); }; @@ -1073,6 +1093,7 @@ const EditChannelModal = (props) => { setIsEnterpriseAccount(false); // 重置豆包隐藏入口状态 setDoubaoApiEditUnlocked(false); + setCodexCredentialMode(CODEX_CREDENTIAL_MODE.API_KEY); doubaoApiClickCountRef.current = 0; // 清空表单中的key_mode字段 if (formApiRef.current) { @@ -1235,6 +1256,9 @@ const EditChannelModal = (props) => { } if (rawKey !== '') { + if (codexCredentialMode === CODEX_CREDENTIAL_MODE.API_KEY) { + localInputs.key = rawKey; + } else { if (!verifyJSON(rawKey)) { showInfo(t('密钥必须是合法的 JSON 格式!')); return; @@ -1260,6 +1284,7 @@ const EditChannelModal = (props) => { showInfo(t('密钥必须是合法的 JSON 格式!')); return; } + } } } @@ -2137,6 +2162,32 @@ const EditChannelModal = (props) => { <> {inputs.type === 57 ? ( <> + setCodexCredentialMode(value)} + style={{ width: '100%' }} + extraText={ + isCodexOAuthMode + ? t( + 'OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据', + ) + : t( + 'API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权', + ) + } + /> { disabled={isIonetLocked} extraText={
- + {isCodexOAuthMode ? ( + {t( '仅支持 JSON 对象,必须包含 access_token 与 account_id', )} - + + ) : ( + + {t( + 'API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权', + )} + + )} - + {isCodexOAuthMode && ( + )} {batchExtra} - + + )}
} autosize diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index ce2f6cd..6182540 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -187,7 +187,7 @@ export const CHANNEL_OPTIONS = [ { value: 57, color: 'blue', - label: 'Codex (OpenAI OAuth)', + label: 'Codex', }, ]; diff --git a/web/src/helpers/codex.js b/web/src/helpers/codex.js new file mode 100644 index 0000000..d189ae0 --- /dev/null +++ b/web/src/helpers/codex.js @@ -0,0 +1,31 @@ +export const CODEX_CREDENTIAL_MODE = { + API_KEY: 'api_key', + OAUTH: 'oauth', +}; + +export function isCodexOAuthCredential(rawKey) { + const trimmed = String(rawKey || '').trim(); + if (!trimmed.startsWith('{')) { + return false; + } + + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return false; + } + + return ( + String(parsed.access_token || '').trim() !== '' && + String(parsed.account_id || '').trim() !== '' + ); + } catch { + return false; + } +} + +export function detectCodexCredentialMode(rawKey) { + return isCodexOAuthCredential(rawKey) + ? CODEX_CREDENTIAL_MODE.OAUTH + : CODEX_CREDENTIAL_MODE.API_KEY; +} diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index b726ad9..81c085f 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -28,6 +28,7 @@ import { copy, toBoolean, } from '../../helpers'; +import { isCodexOAuthCredential } from '../../helpers/codex'; import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, @@ -747,7 +748,7 @@ export const useChannelsData = () => { }; const updateChannelBalance = async (record) => { - if (record?.type === 57) { + if (record?.type === 57 && isCodexOAuthCredential(record?.key)) { openCodexUsageModal({ t, record, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 4b8d566..121a702 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -38,9 +38,15 @@ "AI编程": "AI Coding", "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key": "AK/SK mode uses AccessKey and SecretAccessKey; API Key mode uses an API Key", "API Key": "API Key", + "API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权": "API Key mode uses a plain-text secret and standard Bearer authentication", "API Key 模式下不支持批量创建": "Batch creation not supported in API Key mode", "API Key 验证失败": "API Key verification failed", "API Key 验证成功!连接到 io.net 服务正常": "API Key verification successful! Connection to io.net service is normal", + "OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据": "OAuth mode requires a JSON credential containing access_token and account_id", + "OAuth 凭据必须包含 access_token 和 account_id": "OAuth credential must include access_token and account_id", + "OAuth 凭据必须是合法的 JSON 对象": "OAuth credential must be a valid JSON object", + "凭证方式": "Credential Mode", + "请输入 Codex API Key": "Enter the Codex API Key", "API 地址和相关配置": "API URL and related configuration", "API 密钥": "API Key", "API 文档": "API Documentation", @@ -3117,4 +3123,4 @@ "必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn", "匹配该后缀的用户注册时获得的初始额度": "Initial quota for users registering with this email suffix", "确认删除该规则?": "Confirm delete this rule?" -} \ No newline at end of file +} diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 2579cea..e3020c7 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -35,9 +35,15 @@ "AI编程": "AI编程", "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key": "AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key", "API Key": "API Key", + "API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权": "API Key 模式使用纯文本密钥,通过标准 Bearer 鉴权", "API Key 模式下不支持批量创建": "API Key 模式下不支持批量创建", "API Key 验证失败": "API Key 验证失败", "API Key 验证成功!连接到 io.net 服务正常": "API Key 验证成功!连接到 io.net 服务正常", + "OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据": "OAuth 模式需要包含 access_token 和 account_id 的 JSON 凭据", + "OAuth 凭据必须包含 access_token 和 account_id": "OAuth 凭据必须包含 access_token 和 account_id", + "OAuth 凭据必须是合法的 JSON 对象": "OAuth 凭据必须是合法的 JSON 对象", + "凭证方式": "凭证方式", + "请输入 Codex API Key": "请输入 Codex API Key", "API 地址和相关配置": "API 地址和相关配置", "API 密钥": "API 密钥", "API 文档": "API 文档", @@ -3094,4 +3100,4 @@ "必须以 @ 开头,如 @company.com、@edu.cn": "必须以 @ 开头,如 @company.com、@edu.cn", "匹配该后缀的用户注册时获得的初始额度": "匹配该后缀的用户注册时获得的初始额度", "确认删除该规则?": "确认删除该规则?" -} \ No newline at end of file +}