Quellcode durchsuchen

feat: implement token key fetching and masking in API responses

CaIon vor 3 Monaten
Ursprung
Commit
d67f446b66

+ 37 - 12
controller/token.go

@@ -14,6 +14,23 @@ import (
 	"github.com/gin-gonic/gin"
 )
 
+func buildMaskedTokenResponse(token *model.Token) *model.Token {
+	if token == nil {
+		return nil
+	}
+	maskedToken := *token
+	maskedToken.Key = token.GetMaskedKey()
+	return &maskedToken
+}
+
+func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
+	maskedTokens := make([]*model.Token, 0, len(tokens))
+	for _, token := range tokens {
+		maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
+	}
+	return maskedTokens
+}
+
 func GetAllTokens(c *gin.Context) {
 	userId := c.GetInt("id")
 	pageInfo := common.GetPageQuery(c)
@@ -24,9 +41,8 @@ func GetAllTokens(c *gin.Context) {
 	}
 	total, _ := model.CountUserTokens(userId)
 	pageInfo.SetTotal(int(total))
-	pageInfo.SetItems(tokens)
+	pageInfo.SetItems(buildMaskedTokenResponses(tokens))
 	common.ApiSuccess(c, pageInfo)
-	return
 }
 
 func SearchTokens(c *gin.Context) {
@@ -42,9 +58,8 @@ func SearchTokens(c *gin.Context) {
 		return
 	}
 	pageInfo.SetTotal(int(total))
-	pageInfo.SetItems(tokens)
+	pageInfo.SetItems(buildMaskedTokenResponses(tokens))
 	common.ApiSuccess(c, pageInfo)
-	return
 }
 
 func GetToken(c *gin.Context) {
@@ -59,12 +74,24 @@ func GetToken(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
-	c.JSON(http.StatusOK, gin.H{
-		"success": true,
-		"message": "",
-		"data":    token,
+	common.ApiSuccess(c, buildMaskedTokenResponse(token))
+}
+
+func GetTokenKey(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	userId := c.GetInt("id")
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	token, err := model.GetTokenByIds(id, userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, gin.H{
+		"key": token.GetFullKey(),
 	})
-	return
 }
 
 func GetTokenStatus(c *gin.Context) {
@@ -204,7 +231,6 @@ func AddToken(c *gin.Context) {
 		"success": true,
 		"message": "",
 	})
-	return
 }
 
 func DeleteToken(c *gin.Context) {
@@ -219,7 +245,6 @@ func DeleteToken(c *gin.Context) {
 		"success": true,
 		"message": "",
 	})
-	return
 }
 
 func UpdateToken(c *gin.Context) {
@@ -283,7 +308,7 @@ func UpdateToken(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
-		"data":    cleanToken,
+		"data":    buildMaskedTokenResponse(cleanToken),
 	})
 }
 

+ 275 - 0
controller/token_test.go

@@ -0,0 +1,275 @@
+package controller
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/model"
+	"github.com/gin-gonic/gin"
+	"github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+)
+
+type tokenAPIResponse struct {
+	Success bool            `json:"success"`
+	Message string          `json:"message"`
+	Data    json.RawMessage `json:"data"`
+}
+
+type tokenPageResponse struct {
+	Items []tokenResponseItem `json:"items"`
+}
+
+type tokenResponseItem struct {
+	ID     int    `json:"id"`
+	Name   string `json:"name"`
+	Key    string `json:"key"`
+	Status int    `json:"status"`
+}
+
+type tokenKeyResponse struct {
+	Key string `json:"key"`
+}
+
+func setupTokenControllerTestDB(t *testing.T) *gorm.DB {
+	t.Helper()
+
+	gin.SetMode(gin.TestMode)
+	common.UsingSQLite = true
+	common.UsingMySQL = false
+	common.UsingPostgreSQL = false
+	common.RedisEnabled = false
+
+	dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("failed to open sqlite db: %v", err)
+	}
+	model.DB = db
+	model.LOG_DB = db
+
+	if err := db.AutoMigrate(&model.Token{}); err != nil {
+		t.Fatalf("failed to migrate token table: %v", err)
+	}
+
+	t.Cleanup(func() {
+		sqlDB, err := db.DB()
+		if err == nil {
+			_ = sqlDB.Close()
+		}
+	})
+
+	return db
+}
+
+func seedToken(t *testing.T, db *gorm.DB, userID int, name string, rawKey string) *model.Token {
+	t.Helper()
+
+	token := &model.Token{
+		UserId:         userID,
+		Name:           name,
+		Key:            rawKey,
+		Status:         common.TokenStatusEnabled,
+		CreatedTime:    1,
+		AccessedTime:   1,
+		ExpiredTime:    -1,
+		RemainQuota:    100,
+		UnlimitedQuota: true,
+		Group:          "default",
+	}
+	if err := db.Create(token).Error; err != nil {
+		t.Fatalf("failed to create token: %v", err)
+	}
+	return token
+}
+
+func newAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
+	t.Helper()
+
+	var requestBody *bytes.Reader
+	if body != nil {
+		payload, err := common.Marshal(body)
+		if err != nil {
+			t.Fatalf("failed to marshal request body: %v", err)
+		}
+		requestBody = bytes.NewReader(payload)
+	} else {
+		requestBody = bytes.NewReader(nil)
+	}
+
+	recorder := httptest.NewRecorder()
+	ctx, _ := gin.CreateTestContext(recorder)
+	ctx.Request = httptest.NewRequest(method, target, requestBody)
+	if body != nil {
+		ctx.Request.Header.Set("Content-Type", "application/json")
+	}
+	ctx.Set("id", userID)
+	return ctx, recorder
+}
+
+func decodeAPIResponse(t *testing.T, recorder *httptest.ResponseRecorder) tokenAPIResponse {
+	t.Helper()
+
+	var response tokenAPIResponse
+	if err := common.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
+		t.Fatalf("failed to decode api response: %v", err)
+	}
+	return response
+}
+
+func TestGetAllTokensMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "list-token", "abcd1234efgh5678")
+	seedToken(t, db, 2, "other-user-token", "zzzz1234yyyy5678")
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/?p=1&size=10", nil, 1)
+	GetAllTokens(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var page tokenPageResponse
+	if err := common.Unmarshal(response.Data, &page); err != nil {
+		t.Fatalf("failed to decode token page response: %v", err)
+	}
+	if len(page.Items) != 1 {
+		t.Fatalf("expected exactly one token, got %d", len(page.Items))
+	}
+	if page.Items[0].Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("list response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestSearchTokensMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678")
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/search?keyword=searchable-token&p=1&size=10", nil, 1)
+	SearchTokens(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var page tokenPageResponse
+	if err := common.Unmarshal(response.Data, &page); err != nil {
+		t.Fatalf("failed to decode search response: %v", err)
+	}
+	if len(page.Items) != 1 {
+		t.Fatalf("expected exactly one search result, got %d", len(page.Items))
+	}
+	if page.Items[0].Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked search key %q, got %q", token.GetMaskedKey(), page.Items[0].Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("search response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestGetTokenMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "detail-token", "qrst1234uvwx5678")
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/token/"+strconv.Itoa(token.Id), nil, 1)
+	ctx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
+	GetToken(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var detail tokenResponseItem
+	if err := common.Unmarshal(response.Data, &detail); err != nil {
+		t.Fatalf("failed to decode token detail response: %v", err)
+	}
+	if detail.Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked detail key %q, got %q", token.GetMaskedKey(), detail.Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("detail response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
+
+	body := map[string]any{
+		"id":                   token.Id,
+		"name":                 "updated-token",
+		"expired_time":         -1,
+		"remain_quota":         100,
+		"unlimited_quota":      true,
+		"model_limits_enabled": false,
+		"model_limits":         "",
+		"group":                "default",
+		"cross_group_retry":    false,
+	}
+
+	ctx, recorder := newAuthenticatedContext(t, http.MethodPut, "/api/token/", body, 1)
+	UpdateToken(ctx)
+
+	response := decodeAPIResponse(t, recorder)
+	if !response.Success {
+		t.Fatalf("expected success response, got message: %s", response.Message)
+	}
+
+	var detail tokenResponseItem
+	if err := common.Unmarshal(response.Data, &detail); err != nil {
+		t.Fatalf("failed to decode token update response: %v", err)
+	}
+	if detail.Key != token.GetMaskedKey() {
+		t.Fatalf("expected masked update key %q, got %q", token.GetMaskedKey(), detail.Key)
+	}
+	if strings.Contains(recorder.Body.String(), token.Key) {
+		t.Fatalf("update response leaked raw token key: %s", recorder.Body.String())
+	}
+}
+
+func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
+	db := setupTokenControllerTestDB(t)
+	token := seedToken(t, db, 1, "owned-token", "owner1234token5678")
+
+	authorizedCtx, authorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 1)
+	authorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
+	GetTokenKey(authorizedCtx)
+
+	authorizedResponse := decodeAPIResponse(t, authorizedRecorder)
+	if !authorizedResponse.Success {
+		t.Fatalf("expected authorized key fetch to succeed, got message: %s", authorizedResponse.Message)
+	}
+
+	var keyData tokenKeyResponse
+	if err := common.Unmarshal(authorizedResponse.Data, &keyData); err != nil {
+		t.Fatalf("failed to decode token key response: %v", err)
+	}
+	if keyData.Key != token.GetFullKey() {
+		t.Fatalf("expected full key %q, got %q", token.GetFullKey(), keyData.Key)
+	}
+
+	unauthorizedCtx, unauthorizedRecorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/"+strconv.Itoa(token.Id)+"/key", nil, 2)
+	unauthorizedCtx.Params = gin.Params{{Key: "id", Value: strconv.Itoa(token.Id)}}
+	GetTokenKey(unauthorizedCtx)
+
+	unauthorizedResponse := decodeAPIResponse(t, unauthorizedRecorder)
+	if unauthorizedResponse.Success {
+		t.Fatalf("expected unauthorized key fetch to fail")
+	}
+	if strings.Contains(unauthorizedRecorder.Body.String(), token.Key) {
+		t.Fatalf("unauthorized key response leaked raw token key: %s", unauthorizedRecorder.Body.String())
+	}
+}

+ 22 - 1
model/token.go

@@ -35,6 +35,27 @@ func (token *Token) Clean() {
 	token.Key = ""
 }
 
+func MaskTokenKey(key string) string {
+	if key == "" {
+		return ""
+	}
+	if len(key) <= 4 {
+		return strings.Repeat("*", len(key))
+	}
+	if len(key) <= 8 {
+		return key[:2] + "****" + key[len(key)-2:]
+	}
+	return key[:4] + "**********" + key[len(key)-4:]
+}
+
+func (token *Token) GetFullKey() string {
+	return token.Key
+}
+
+func (token *Token) GetMaskedKey() string {
+	return MaskTokenKey(token.Key)
+}
+
 func (token *Token) GetIpLimits() []string {
 	// delete empty spaces
 	//split with \n
@@ -201,7 +222,7 @@ func ValidateUserToken(key string) (token *Token, err error) {
 			}
 			keyPrefix := key[:3]
 			keySuffix := key[len(key)-3:]
-			return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota))
+			return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
 		}
 		return token, nil
 	}

+ 1 - 0
router/api-router.go

@@ -248,6 +248,7 @@ func SetApiRouter(router *gin.Engine) {
 			tokenRoute.GET("/", controller.GetAllTokens)
 			tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
 			tokenRoute.GET("/:id", controller.GetToken)
+			tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey)
 			tokenRoute.POST("/", controller.AddToken)
 			tokenRoute.PUT("/", controller.UpdateToken)
 			tokenRoute.DELETE("/:id", controller.DeleteToken)

+ 1 - 3
web/src/components/table/tokens/TokensActions.jsx

@@ -29,7 +29,6 @@ const TokensActions = ({
   setShowEdit,
   batchCopyTokens,
   batchDeleteTokens,
-  copyText,
   t,
 }) => {
   // Modal states
@@ -99,8 +98,7 @@ const TokensActions = ({
       <CopyTokensModal
         visible={showCopyModal}
         onCancel={() => setShowCopyModal(false)}
-        selectedKeys={selectedKeys}
-        copyText={copyText}
+        batchCopyTokens={batchCopyTokens}
         t={t}
       />
 

+ 34 - 11
web/src/components/table/tokens/TokensColumnDefs.jsx

@@ -108,17 +108,28 @@ const renderGroupColumn = (text, record, t) => {
 };
 
 // Render token key column with show/hide and copy functionality
-const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
-  const fullKey = 'sk-' + record.key;
-  const maskedKey =
-    'sk-' + record.key.slice(0, 4) + '**********' + record.key.slice(-4);
+const renderTokenKey = (
+  text,
+  record,
+  showKeys,
+  resolvedTokenKeys,
+  loadingTokenKeys,
+  toggleTokenVisibility,
+  copyTokenKey,
+) => {
   const revealed = !!showKeys[record.id];
+  const loading = !!loadingTokenKeys[record.id];
+  const keyValue =
+    revealed && resolvedTokenKeys[record.id]
+      ? resolvedTokenKeys[record.id]
+      : record.key || '';
+  const displayedKey = keyValue ? `sk-${keyValue}` : '';
 
   return (
     <div className='w-[200px]'>
       <Input
         readOnly
-        value={revealed ? fullKey : maskedKey}
+        value={displayedKey}
         size='small'
         suffix={
           <div className='flex items-center'>
@@ -127,10 +138,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
               size='small'
               type='tertiary'
               icon={revealed ? <IconEyeClosed /> : <IconEyeOpened />}
+              loading={loading}
               aria-label='toggle token visibility'
-              onClick={(e) => {
+              onClick={async (e) => {
                 e.stopPropagation();
-                setShowKeys((prev) => ({ ...prev, [record.id]: !revealed }));
+                await toggleTokenVisibility(record);
               }}
             />
             <Button
@@ -138,10 +150,11 @@ const renderTokenKey = (text, record, showKeys, setShowKeys, copyText) => {
               size='small'
               type='tertiary'
               icon={<IconCopy />}
+              loading={loading}
               aria-label='copy token key'
               onClick={async (e) => {
                 e.stopPropagation();
-                await copyText(fullKey);
+                await copyTokenKey(record);
               }}
             />
           </div>
@@ -427,8 +440,10 @@ const renderOperations = (
 export const getTokensColumns = ({
   t,
   showKeys,
-  setShowKeys,
-  copyText,
+  resolvedTokenKeys,
+  loadingTokenKeys,
+  toggleTokenVisibility,
+  copyTokenKey,
   manageToken,
   onOpenLink,
   setEditingToken,
@@ -461,7 +476,15 @@ export const getTokensColumns = ({
       title: t('密钥'),
       key: 'token_key',
       render: (text, record) =>
-        renderTokenKey(text, record, showKeys, setShowKeys, copyText),
+        renderTokenKey(
+          text,
+          record,
+          showKeys,
+          resolvedTokenKeys,
+          loadingTokenKeys,
+          toggleTokenVisibility,
+          copyTokenKey,
+        ),
     },
     {
       title: t('可用模型'),

+ 12 - 6
web/src/components/table/tokens/TokensTable.jsx

@@ -39,8 +39,10 @@ const TokensTable = (tokensData) => {
     rowSelection,
     handleRow,
     showKeys,
-    setShowKeys,
-    copyText,
+    resolvedTokenKeys,
+    loadingTokenKeys,
+    toggleTokenVisibility,
+    copyTokenKey,
     manageToken,
     onOpenLink,
     setEditingToken,
@@ -54,8 +56,10 @@ const TokensTable = (tokensData) => {
     return getTokensColumns({
       t,
       showKeys,
-      setShowKeys,
-      copyText,
+      resolvedTokenKeys,
+      loadingTokenKeys,
+      toggleTokenVisibility,
+      copyTokenKey,
       manageToken,
       onOpenLink,
       setEditingToken,
@@ -65,8 +69,10 @@ const TokensTable = (tokensData) => {
   }, [
     t,
     showKeys,
-    setShowKeys,
-    copyText,
+    resolvedTokenKeys,
+    loadingTokenKeys,
+    toggleTokenVisibility,
+    copyTokenKey,
     manageToken,
     onOpenLink,
     setEditingToken,

+ 10 - 4
web/src/components/table/tokens/index.jsx

@@ -58,6 +58,7 @@ function TokensPage() {
     t: (k) => k,
     selectedModel: '',
     prefillKey: '',
+    fetchTokenKey: async () => '',
   });
   const [modelOptions, setModelOptions] = useState([]);
   const [selectedModel, setSelectedModel] = useState('');
@@ -74,6 +75,7 @@ function TokensPage() {
       t: tokensData.t,
       selectedModel,
       prefillKey,
+      fetchTokenKey: tokensData.fetchTokenKey,
     };
   }, [
     tokensData.tokens,
@@ -81,6 +83,7 @@ function TokensPage() {
     tokensData.t,
     selectedModel,
     prefillKey,
+    tokensData.fetchTokenKey,
   ]);
 
   const loadModels = async () => {
@@ -198,13 +201,14 @@ function TokensPage() {
   openCCSwitchModalRef.current = openCCSwitchModal;
 
   // Prefill to Fluent handler
-  const handlePrefillToFluent = () => {
+  const handlePrefillToFluent = async () => {
     const {
       tokens,
       selectedKeys,
       t,
       selectedModel: chosenModel,
       prefillKey: overrideKey,
+      fetchTokenKey,
     } = latestRef.current;
     const container = document.getElementById('fluent-new-api-container');
     if (!container) {
@@ -241,7 +245,11 @@ function TokensPage() {
         Toast.warning(t('没有可用令牌用于填充'));
         return;
       }
-      apiKeyToUse = 'sk-' + token.key;
+      try {
+        apiKeyToUse = 'sk-' + (await fetchTokenKey(token));
+      } catch (_) {
+        return;
+      }
     }
 
     const payload = {
@@ -351,7 +359,6 @@ function TokensPage() {
     setShowEdit,
     batchCopyTokens,
     batchDeleteTokens,
-    copyText,
 
     // Filters state
     formInitValues,
@@ -401,7 +408,6 @@ function TokensPage() {
               setShowEdit={setShowEdit}
               batchCopyTokens={batchCopyTokens}
               batchDeleteTokens={batchDeleteTokens}
-              copyText={copyText}
               t={t}
             />
 

+ 1 - 2
web/src/components/table/tokens/modals/CCSwitchModal.jsx

@@ -116,8 +116,7 @@ export default function CCSwitchModal({
       Toast.warning(t('请选择主模型'));
       return;
     }
-    const apiKey = 'sk-' + tokenKey;
-    const url = buildCCSwitchURL(app, name, models, apiKey);
+    const url = buildCCSwitchURL(app, name, models, 'sk-' + tokenKey);
     window.open(url, '_blank');
     onClose();
   };

+ 8 - 11
web/src/components/table/tokens/modals/CopyTokensModal.jsx

@@ -20,24 +20,21 @@ For commercial licensing, please contact support@quantumnous.com
 import React from 'react';
 import { Modal, Button, Space } from '@douyinfe/semi-ui';
 
-const CopyTokensModal = ({ visible, onCancel, selectedKeys, copyText, t }) => {
+const CopyTokensModal = ({
+  visible,
+  onCancel,
+  batchCopyTokens,
+  t,
+}) => {
   // Handle copy with name and key format
   const handleCopyWithName = async () => {
-    let content = '';
-    for (let i = 0; i < selectedKeys.length; i++) {
-      content += selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
-    }
-    await copyText(content);
+    await batchCopyTokens('name+key');
     onCancel();
   };
 
   // Handle copy with key only format
   const handleCopyKeyOnly = async () => {
-    let content = '';
-    for (let i = 0; i < selectedKeys.length; i++) {
-      content += 'sk-' + selectedKeys[i].key + '\n';
-    }
-    await copyText(content);
+    await batchCopyTokens('key-only');
     onCancel();
   };
 

+ 1 - 1
web/src/components/table/usage-logs/modals/ColumnSelectorModal.jsx

@@ -73,7 +73,7 @@ const ColumnSelectorModal = ({
           <RadioGroup
             type='button'
             value={billingDisplayMode}
-            onChange={(event) => setBillingDisplayMode(event.target.value)}
+            onChange={(value) => setBillingDisplayMode(value)}
           >
             <Radio value='price'>
               {isTokensDisplay ? t('价格模式') : t('价格模式(默认)')}

+ 22 - 3
web/src/helpers/token.js

@@ -20,8 +20,22 @@ For commercial licensing, please contact support@quantumnous.com
 import { API } from './api';
 
 /**
- * 获取可用的token keys
- * @returns {Promise<string[]>} 返回active状态的token key数组
+ * 按需获取单个令牌的真实 key
+ * @param {number|string} tokenId
+ * @returns {Promise<string>} 返回不带 sk- 前缀的真实 token key
+ */
+export async function fetchTokenKey(tokenId) {
+  const response = await API.post(`/api/token/${tokenId}/key`);
+  const { success, data, message } = response.data || {};
+  if (!success || !data?.key) {
+    throw new Error(message || 'Failed to fetch token key');
+  }
+  return data.key;
+}
+
+/**
+ * 获取可用的 token keys
+ * @returns {Promise<string[]>} 返回 active 状态的不带 sk- 前缀的真实 token key 数组
  */
 export async function fetchTokenKeys() {
   try {
@@ -31,7 +45,12 @@ export async function fetchTokenKeys() {
 
     const tokenItems = Array.isArray(data) ? data : data.items || [];
     const activeTokens = tokenItems.filter((token) => token.status === 1);
-    return activeTokens.map((token) => token.key);
+    const keyResults = await Promise.allSettled(
+      activeTokens.map((token) => fetchTokenKey(token.id)),
+    );
+    return keyResults
+      .filter((result) => result.status === 'fulfilled' && result.value)
+      .map((result) => result.value);
   } catch (error) {
     console.error('Error fetching token keys:', error);
     return [];

+ 106 - 44
web/src/hooks/tokens/useTokensData.jsx

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Modal } from '@douyinfe/semi-ui';
 import {
@@ -29,6 +29,7 @@ import {
 } from '../../helpers';
 import { ITEMS_PER_PAGE } from '../../constants';
 import { useTableCompactMode } from '../common/useTableCompactMode';
+import { fetchTokenKey as fetchTokenKeyById } from '../../helpers/token';
 
 export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
   const { t } = useTranslation();
@@ -54,6 +55,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
   // UI state
   const [compactMode, setCompactMode] = useTableCompactMode('tokens');
   const [showKeys, setShowKeys] = useState({});
+  const [resolvedTokenKeys, setResolvedTokenKeys] = useState({});
+  const [loadingTokenKeys, setLoadingTokenKeys] = useState({});
+  const keyRequestsRef = useRef({});
 
   // Form state
   const [formApi, setFormApi] = useState(null);
@@ -87,6 +91,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     setTokenCount(payload.total || 0);
     setActivePage(payload.page || 1);
     setPageSize(payload.page_size || pageSize);
+    setShowKeys({});
   };
 
   // Load tokens function
@@ -122,14 +127,86 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     }
   };
 
+  const fetchTokenKey = async (tokenOrId, options = {}) => {
+    const { suppressError = false } = options;
+    const tokenId =
+      typeof tokenOrId === 'object' ? tokenOrId?.id : Number(tokenOrId);
+
+    if (!tokenId) {
+      const error = new Error(t('令牌不存在'));
+      if (!suppressError) {
+        showError(error.message);
+      }
+      throw error;
+    }
+
+    if (resolvedTokenKeys[tokenId]) {
+      return resolvedTokenKeys[tokenId];
+    }
+
+    if (keyRequestsRef.current[tokenId]) {
+      return keyRequestsRef.current[tokenId];
+    }
+
+    const request = (async () => {
+      setLoadingTokenKeys((prev) => ({ ...prev, [tokenId]: true }));
+      try {
+        const fullKey = await fetchTokenKeyById(tokenId);
+        setResolvedTokenKeys((prev) => ({ ...prev, [tokenId]: fullKey }));
+        return fullKey;
+      } catch (error) {
+        const normalizedError = new Error(
+          error?.message || t('获取令牌密钥失败'),
+        );
+        if (!suppressError) {
+          showError(normalizedError.message);
+        }
+        throw normalizedError;
+      } finally {
+        delete keyRequestsRef.current[tokenId];
+        setLoadingTokenKeys((prev) => {
+          const next = { ...prev };
+          delete next[tokenId];
+          return next;
+        });
+      }
+    })();
+
+    keyRequestsRef.current[tokenId] = request;
+    return request;
+  };
+
+  const toggleTokenVisibility = async (record) => {
+    const tokenId = record?.id;
+    if (!tokenId) {
+      return;
+    }
+
+    if (showKeys[tokenId]) {
+      setShowKeys((prev) => ({ ...prev, [tokenId]: false }));
+      return;
+    }
+
+    const fullKey = await fetchTokenKey(record);
+    if (fullKey) {
+      setShowKeys((prev) => ({ ...prev, [tokenId]: true }));
+    }
+  };
+
+  const copyTokenKey = async (record) => {
+    const fullKey = await fetchTokenKey(record);
+    await copyText(`sk-${fullKey}`);
+  };
+
   // Open link function for chat integrations
   const onOpenLink = async (type, url, record) => {
+    const fullKey = await fetchTokenKey(record);
     if (url && url.startsWith('ccswitch')) {
-      openCCSwitchModal(record.key);
+      openCCSwitchModal(fullKey);
       return;
     }
     if (url && url.startsWith('fluent')) {
-      openFluentNotification(record.key);
+      openFluentNotification(fullKey);
       return;
     }
     let status = localStorage.getItem('status');
@@ -145,7 +222,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
       let cherryConfig = {
         id: 'new-api',
         baseUrl: serverAddress,
-        apiKey: 'sk-' + record.key,
+        apiKey: `sk-${fullKey}`,
       };
       let encodedConfig = encodeURIComponent(
         encodeToBase64(JSON.stringify(cherryConfig)),
@@ -155,7 +232,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
       let aionuiConfig = {
         platform: 'new-api',
         baseUrl: serverAddress,
-        apiKey: 'sk-' + record.key,
+        apiKey: `sk-${fullKey}`,
       };
       let encodedConfig = encodeURIComponent(
         encodeToBase64(JSON.stringify(aionuiConfig)),
@@ -164,7 +241,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     } else {
       let encodedServerAddress = encodeURIComponent(serverAddress);
       url = url.replaceAll('{address}', encodedServerAddress);
-      url = url.replaceAll('{key}', 'sk-' + record.key);
+      url = url.replaceAll('{key}', `sk-${fullKey}`);
     }
 
     window.open(url, '_blank');
@@ -314,48 +391,28 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
   };
 
   // Batch copy tokens
-  const batchCopyTokens = (copyType) => {
+  const batchCopyTokens = async (copyType) => {
     if (selectedKeys.length === 0) {
       showError(t('请至少选择一个令牌!'));
       return;
     }
-
-    Modal.info({
-      title: t('复制令牌'),
-      icon: null,
-      content: t('请选择你的复制方式'),
-      footer: (
-        <div className='flex gap-2'>
-          <button
-            className='px-3 py-1 bg-gray-200 rounded'
-            onClick={async () => {
-              let content = '';
-              for (let i = 0; i < selectedKeys.length; i++) {
-                content +=
-                  selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
-              }
-              await copyText(content);
-              Modal.destroyAll();
-            }}
-          >
-            {t('名称+密钥')}
-          </button>
-          <button
-            className='px-3 py-1 bg-blue-500 text-white rounded'
-            onClick={async () => {
-              let content = '';
-              for (let i = 0; i < selectedKeys.length; i++) {
-                content += 'sk-' + selectedKeys[i].key + '\n';
-              }
-              await copyText(content);
-              Modal.destroyAll();
-            }}
-          >
-            {t('仅密钥')}
-          </button>
-        </div>
-      ),
-    });
+    try {
+      const keys = await Promise.all(
+        selectedKeys.map((token) => fetchTokenKey(token, { suppressError: true })),
+      );
+      let content = '';
+      for (let i = 0; i < selectedKeys.length; i++) {
+        const fullKey = keys[i];
+        if (copyType === 'name+key') {
+          content += `${selectedKeys[i].name}    sk-${fullKey}\n`;
+        } else {
+          content += `sk-${fullKey}\n`;
+        }
+      }
+      await copyText(content);
+    } catch (error) {
+      showError(error?.message || t('复制令牌失败'));
+    }
   };
 
   // Initialize data
@@ -392,6 +449,8 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     setCompactMode,
     showKeys,
     setShowKeys,
+    resolvedTokenKeys,
+    loadingTokenKeys,
 
     // Form state
     formApi,
@@ -403,6 +462,9 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     loadTokens,
     refresh,
     copyText,
+    fetchTokenKey,
+    toggleTokenVisibility,
+    copyTokenKey,
     onOpenLink,
     manageToken,
     searchTokens,