Skip to content

Commit e4657ca

Browse files
authored
feat: Optimize massive keys (#371)
* feat: Support txt file upload * feat: Optimize Batch Cache * feat: Keys query index
1 parent fd24d7a commit e4657ca

File tree

13 files changed

+282
-39
lines changed

13 files changed

+282
-39
lines changed

internal/handler/key_handler.go

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
app_errors "gpt-load/internal/errors"
66
"gpt-load/internal/models"
77
"gpt-load/internal/response"
8+
"io"
89
"log"
10+
"path/filepath"
911
"strconv"
1012
"strings"
1113
"time"
@@ -107,24 +109,79 @@ func (s *Server) AddMultipleKeys(c *gin.Context) {
107109
response.Success(c, result)
108110
}
109111

110-
// AddMultipleKeysAsync handles creating new keys from a text block within a specific group.
112+
// AddMultipleKeysAsync handles creating new keys from a text block or file within a specific group.
111113
func (s *Server) AddMultipleKeysAsync(c *gin.Context) {
112-
var req KeyTextRequest
113-
if err := c.ShouldBindJSON(&req); err != nil {
114-
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
115-
return
114+
var groupID uint
115+
var keysText string
116+
117+
// Check content type to determine if it's a file upload or JSON request
118+
contentType := c.ContentType()
119+
120+
if strings.Contains(contentType, "multipart/form-data") {
121+
// Handle file upload
122+
groupIDStr := c.PostForm("group_id")
123+
if groupIDStr == "" {
124+
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.group_id_required")
125+
return
126+
}
127+
128+
groupIDInt, err := strconv.Atoi(groupIDStr)
129+
if err != nil || groupIDInt <= 0 {
130+
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id_format")
131+
return
132+
}
133+
groupID = uint(groupIDInt)
134+
135+
// Get uploaded file
136+
file, err := c.FormFile("file")
137+
if err != nil {
138+
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.file_required")
139+
return
140+
}
141+
142+
// Validate file extension
143+
ext := strings.ToLower(filepath.Ext(file.Filename))
144+
if ext != ".txt" {
145+
response.ErrorI18nFromAPIError(c, app_errors.ErrValidation, "validation.only_txt_supported")
146+
return
147+
}
148+
149+
// Read file content
150+
fileContent, err := file.Open()
151+
if err != nil {
152+
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.failed_to_open_file")
153+
return
154+
}
155+
defer fileContent.Close()
156+
157+
// Read file content as string using io.ReadAll
158+
buf, err := io.ReadAll(fileContent)
159+
if err != nil {
160+
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.failed_to_read_file")
161+
return
162+
}
163+
keysText = string(buf)
164+
} else {
165+
// Handle JSON request (original behavior)
166+
var req KeyTextRequest
167+
if err := c.ShouldBindJSON(&req); err != nil {
168+
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
169+
return
170+
}
171+
groupID = req.GroupID
172+
keysText = req.KeysText
116173
}
117174

118-
group, ok := s.findGroupByID(c, req.GroupID)
175+
group, ok := s.findGroupByID(c, groupID)
119176
if !ok {
120177
return
121178
}
122179

123-
if !validateKeysText(c, req.KeysText) {
180+
if !validateKeysText(c, keysText) {
124181
return
125182
}
126183

127-
taskStatus, err := s.KeyImportService.StartImportTask(group, req.KeysText)
184+
taskStatus, err := s.KeyImportService.StartImportTask(group, keysText)
128185
if err != nil {
129186
response.Error(c, app_errors.NewAPIError(app_errors.ErrTaskInProgress, err.Error()))
130187
return

internal/i18n/locales/en-US.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ var MessagesEnUS = map[string]string{
6060
"validation.group_id_required": "group_id query parameter is required",
6161
"validation.invalid_group_id_format": "Invalid group_id format",
6262
"validation.keys_text_empty": "Keys text cannot be empty",
63+
"validation.file_required": "File is required",
64+
"validation.only_txt_supported": "Only .txt files are supported",
65+
"validation.failed_to_open_file": "Failed to open file",
66+
"validation.failed_to_read_file": "Failed to read file content",
6367
"validation.invalid_group_type": "Invalid group type, must be 'standard' or 'aggregate'",
6468
"validation.sub_groups_required": "Aggregate group must contain at least one sub-group",
6569
"validation.invalid_sub_group_id": "Invalid sub-group ID",

internal/i18n/locales/ja-JP.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ var MessagesJaJP = map[string]string{
6060
"validation.group_id_required": "group_idクエリパラメータが必要です",
6161
"validation.invalid_group_id_format": "無効なgroup_id形式",
6262
"validation.keys_text_empty": "キーテキストは空にできません",
63+
"validation.file_required": "ファイルが必要です",
64+
"validation.only_txt_supported": ".txtファイルのみサポートされています",
65+
"validation.failed_to_open_file": "ファイルを開けませんでした",
66+
"validation.failed_to_read_file": "ファイルの内容を読み取れませんでした",
6367
"validation.invalid_group_type": "無効なグループタイプ、'standard'または'aggregate'である必要があります",
6468
"validation.sub_groups_required": "集約グループには少なくとも1つのサブグループが必要です",
6569
"validation.invalid_sub_group_id": "無効なサブグループID",

internal/i18n/locales/zh-CN.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ var MessagesZhCN = map[string]string{
6060
"validation.group_id_required": "需要提供group_id参数",
6161
"validation.invalid_group_id_format": "无效的group_id格式",
6262
"validation.keys_text_empty": "密钥文本不能为空",
63+
"validation.file_required": "需要上传文件",
64+
"validation.only_txt_supported": "仅支持.txt文件",
65+
"validation.failed_to_open_file": "无法打开文件",
66+
"validation.failed_to_read_file": "无法读取文件内容",
6367
"validation.invalid_group_type": "无效的分组类型,必须为'standard'或'aggregate'",
6468
"validation.sub_groups_required": "聚合分组必须包含至少一个子分组",
6569
"validation.invalid_sub_group_id": "无效的子分组ID",

internal/keypool/provider.go

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ func (p *KeyProvider) LoadKeysFromDB() error {
242242

243243
// 1. 分批从数据库加载并使用 Pipeline 写入 Redis
244244
allActiveKeyIDs := make(map[uint][]any)
245-
batchSize := 1000
245+
batchSize := 10000
246246
var batchKeys []*models.APIKey
247247

248248
err := p.db.Model(&models.APIKey{}).FindInBatches(&batchKeys, batchSize, func(tx *gorm.DB, batch int) error {
@@ -308,13 +308,8 @@ func (p *KeyProvider) AddKeys(groupID uint, keys []models.APIKey) error {
308308
return err
309309
}
310310

311-
for _, key := range keys {
312-
if err := p.addKeyToStore(&key); err != nil {
313-
logrus.WithFields(logrus.Fields{"keyID": key.ID, "error": err}).Error("Failed to add key to store after DB creation, rolling back transaction")
314-
return err
315-
}
316-
}
317-
return nil
311+
// 使用批量方法添加到缓存
312+
return p.addKeysToCacheBatch(groupID, keys)
318313
})
319314

320315
return err
@@ -577,6 +572,48 @@ func (p *KeyProvider) addKeyToStore(key *models.APIKey) error {
577572
return nil
578573
}
579574

575+
// addKeysToCacheBatch 批量添加密钥到缓存(用于批量导入场景)
576+
func (p *KeyProvider) addKeysToCacheBatch(groupID uint, keys []models.APIKey) error {
577+
if len(keys) == 0 {
578+
return nil
579+
}
580+
581+
// 1. 批量 HSet 密钥详情
582+
if pipeliner, ok := p.store.(store.RedisPipeliner); ok {
583+
// Redis: 使用 Pipeline 批量操作
584+
pipe := pipeliner.Pipeline()
585+
for i := range keys {
586+
keyHashKey := fmt.Sprintf("key:%d", keys[i].ID)
587+
pipe.HSet(keyHashKey, p.apiKeyToMap(&keys[i]))
588+
}
589+
if err := pipe.Exec(); err != nil {
590+
return fmt.Errorf("failed to batch HSet keys: %w", err)
591+
}
592+
} else {
593+
// MemoryStore: 降级为逐个 HSet
594+
for i := range keys {
595+
keyHashKey := fmt.Sprintf("key:%d", keys[i].ID)
596+
if err := p.store.HSet(keyHashKey, p.apiKeyToMap(&keys[i])); err != nil {
597+
return fmt.Errorf("failed to HSet key %d: %w", keys[i].ID, err)
598+
}
599+
}
600+
}
601+
602+
// 2. 收集所有密钥 ID
603+
activeKeysListKey := fmt.Sprintf("group:%d:active_keys", groupID)
604+
activeKeyIDs := make([]any, len(keys))
605+
for i := range keys {
606+
activeKeyIDs[i] = keys[i].ID
607+
}
608+
609+
// 3. 批量 LPush 活跃密钥
610+
if err := p.store.LPush(activeKeysListKey, activeKeyIDs...); err != nil {
611+
return fmt.Errorf("failed to batch LPush keys to group %d: %w", groupID, err)
612+
}
613+
614+
return nil
615+
}
616+
580617
// removeKeyFromStore is a helper to remove a single key from the cache.
581618
func (p *KeyProvider) removeKeyFromStore(keyID, groupID uint) error {
582619
activeKeysListKey := fmt.Sprintf("group:%d:active_keys", groupID)

internal/models/types.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,10 @@ type APIKey struct {
115115
KeyValue string `gorm:"type:text;not null" json:"key_value"`
116116
KeyHash string `gorm:"type:varchar(128);index" json:"key_hash"`
117117
GroupID uint `gorm:"not null;index" json:"group_id"`
118-
Status string `gorm:"type:varchar(50);not null;default:'active'" json:"status"`
118+
Status string `gorm:"type:varchar(50);not null;default:'active';index" json:"status"`
119119
Notes string `gorm:"type:varchar(255);default:''" json:"notes"`
120120
RequestCount int64 `gorm:"not null;default:0" json:"request_count"`
121121
FailureCount int64 `gorm:"not null;default:0" json:"failure_count"`
122-
LastUsedAt *time.Time `json:"last_used_at"`
123122
CreatedAt time.Time `json:"created_at"`
124123
UpdatedAt time.Time `json:"updated_at"`
125124
}

internal/services/key_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ func (s *KeyService) ListKeysInGroupQuery(groupID uint, statusFilter string, sea
301301
query = query.Where("key_hash = ?", searchHash)
302302
}
303303

304-
query = query.Order("last_used_at desc, updated_at desc")
304+
query = query.Order("id desc")
305305

306306
return query
307307
}

internal/services/request_log_service.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,6 @@ func (s *RequestLogService) writeLogsToDB(logs []*models.RequestLog) error {
227227
if err := tx.Model(&models.APIKey{}).Where("key_hash IN ?", keyHashes).
228228
Updates(map[string]any{
229229
"request_count": gorm.Expr(caseStmt.String()),
230-
"last_used_at": time.Now(),
231230
}).Error; err != nil {
232231
return fmt.Errorf("failed to batch update api_key stats: %w", err)
233232
}

web/src/api/keys.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,25 @@ export const keysApi = {
102102
},
103103

104104
// 异步批量添加密钥
105-
async addKeysAsync(group_id: number, keys_text: string): Promise<TaskInfo> {
106-
const res = await http.post(
107-
"/keys/add-async",
108-
{
109-
group_id,
110-
keys_text,
111-
},
112-
{
113-
hideMessage: true,
114-
}
115-
);
105+
async addKeysAsync(group_id: number, keys_text?: string, file?: File): Promise<TaskInfo> {
106+
let requestData: FormData | { group_id: number; keys_text: string };
107+
const config: { hideMessage: boolean; headers?: { "Content-Type": string } } = {
108+
hideMessage: true,
109+
};
110+
111+
if (file) {
112+
// File upload mode
113+
const formData = new FormData();
114+
formData.append("group_id", group_id.toString());
115+
formData.append("file", file);
116+
requestData = formData;
117+
config.headers = { "Content-Type": "multipart/form-data" };
118+
} else {
119+
// Text input mode
120+
requestData = { group_id, keys_text: keys_text || "" };
121+
}
122+
123+
const res = await http.post("/keys/add-async", requestData, config);
116124
return res.data;
117125
},
118126

0 commit comments

Comments
 (0)