feat(s2a): Add dynamic group fetching with credentials validation

- Add backend endpoint `/api/s2a/fetch-groups` to fetch S2A groups using arbitrary credentials without depending on global config
- Implement `handleFetchGroups` handler with proper error handling and multiple auth header support (Bearer token, X-API-Key, X-Admin-Key)
- Add frontend API function `fetchGroupsWithCredentials` to call the new endpoint and parse S2A standard response format
- Add group fetching UI in S2AConfig edit mode with refresh button and loading state
- Implement batch group name caching in list view to display human-readable group names instead of IDs
- Deduplicate API requests by grouping profiles with identical credentials to avoid redundant calls
- Add fallback to display group ID when name is unavailable
This commit is contained in:
2026-02-07 18:36:09 +08:00
parent 0ad5881259
commit 6eb156c555
3 changed files with 207 additions and 13 deletions

View File

@@ -124,6 +124,7 @@ func startServer(cfg *config.Config) {
mux.HandleFunc("/api/s2a/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号
mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置
mux.HandleFunc("/api/s2a/profiles", api.CORS(handleS2AProfiles)) // S2A 配置预设管理
mux.HandleFunc("/api/s2a/fetch-groups", api.CORS(handleFetchGroups)) // 用任意凭据获取分组列表
// 统计 API
mux.HandleFunc("/api/stats/max-rpm", api.CORS(api.HandleGetMaxRPM)) // 今日最高 RPM
@@ -667,6 +668,59 @@ func handleS2AProxy(w http.ResponseWriter, r *http.Request) {
w.Write(bodyBytes)
}
// handleFetchGroups 使用任意 api_base + admin_key 获取 S2A 分组列表(不依赖 config.Global
func handleFetchGroups(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
return
}
var req struct {
ApiBase string `json:"api_base"`
AdminKey string `json:"admin_key"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.Error(w, http.StatusBadRequest, "请求格式错误")
return
}
if req.ApiBase == "" || req.AdminKey == "" {
api.Error(w, http.StatusBadRequest, "api_base 和 admin_key 不能为空")
return
}
targetURL := strings.TrimRight(req.ApiBase, "/") + "/api/v1/admin/groups/all"
proxyReq, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
api.Error(w, http.StatusInternalServerError, "创建请求失败")
return
}
proxyReq.Header.Set("Authorization", "Bearer "+req.AdminKey)
proxyReq.Header.Set("X-API-Key", req.AdminKey)
proxyReq.Header.Set("X-Admin-Key", req.AdminKey)
proxyReq.Header.Set("Content-Type", "application/json")
proxyReq.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(proxyReq)
if err != nil {
api.Error(w, http.StatusBadGateway, fmt.Sprintf("请求 S2A 失败: %v", err))
return
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
api.Error(w, http.StatusBadGateway, "读取响应失败")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(bodyBytes)
}
func handleMailServices(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":