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/clean-errors", api.CORS(api.HandleCleanErrorAccounts)) // 清理错误账号
mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置 mux.HandleFunc("/api/s2a/cleaner/settings", api.CORS(handleCleanerSettings)) // 清理服务设置
mux.HandleFunc("/api/s2a/profiles", api.CORS(handleS2AProfiles)) // S2A 配置预设管理 mux.HandleFunc("/api/s2a/profiles", api.CORS(handleS2AProfiles)) // S2A 配置预设管理
mux.HandleFunc("/api/s2a/fetch-groups", api.CORS(handleFetchGroups)) // 用任意凭据获取分组列表
// 统计 API // 统计 API
mux.HandleFunc("/api/stats/max-rpm", api.CORS(api.HandleGetMaxRPM)) // 今日最高 RPM 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) 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) { func handleMailServices(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case "GET": case "GET":

View File

@@ -165,3 +165,26 @@ export class S2AClient {
export function createS2AClient(baseUrl: string, apiKey: string): S2AClient { export function createS2AClient(baseUrl: string, apiKey: string): S2AClient {
return new S2AClient({ baseUrl, apiKey }) return new S2AClient({ baseUrl, apiKey })
} }
// 使用任意凭据获取 S2A 分组列表(不依赖当前活动配置)
export async function fetchGroupsWithCredentials(apiBase: string, adminKey: string): Promise<GroupResponse[]> {
const response = await fetch('/api/s2a/fetch-groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_base: apiBase, admin_key: adminKey }),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`)
}
const json = await response.json()
// S2A 标准响应: { code: 0, data: { data: [...] } }
if (json && json.code === 0 && json.data) {
return json.data.data || json.data || []
}
throw new Error(json.message || '获取分组失败')
}

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X, Globe, ToggleLeft, ToggleRight, Trash2, ChevronLeft, Zap, Edit2 } from 'lucide-react' import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X, Globe, ToggleLeft, ToggleRight, Trash2, ChevronLeft, Zap, Edit2, RefreshCw } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common' import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig' import { useConfig } from '../hooks/useConfig'
import { fetchGroupsWithCredentials } from '../api/s2a'
import type { GroupResponse } from '../api/types'
import type { S2AProfile } from '../types' import type { S2AProfile } from '../types'
type ViewMode = 'list' | 'edit' type ViewMode = 'list' | 'edit'
@@ -26,6 +28,14 @@ export default function S2AConfig() {
const [formProxyEnabled, setFormProxyEnabled] = useState(false) const [formProxyEnabled, setFormProxyEnabled] = useState(false)
const [formProxyAddress, setFormProxyAddress] = useState('') const [formProxyAddress, setFormProxyAddress] = useState('')
// 分组列表相关
const [availableGroups, setAvailableGroups] = useState<GroupResponse[]>([])
const [fetchingGroups, setFetchingGroups] = useState(false)
const [groupsFetched, setGroupsFetched] = useState(false)
// 列表视图分组名称缓存: { [groupId]: groupName }
const [groupNameCache, setGroupNameCache] = useState<Record<number, string>>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<boolean | null>(null) const [testResult, setTestResult] = useState<boolean | null>(null)
@@ -58,6 +68,32 @@ export default function S2AConfig() {
Promise.all([fetchProfiles(), fetchActiveConfig()]).finally(() => setLoading(false)) Promise.all([fetchProfiles(), fetchActiveConfig()]).finally(() => setLoading(false))
}, [fetchProfiles, fetchActiveConfig]) }, [fetchProfiles, fetchActiveConfig])
// 列表视图: 批量获取各 profile 的分组名称
useEffect(() => {
if (viewMode !== 'list' || profiles.length === 0) return
const fetchGroupNames = async () => {
const cache: Record<number, string> = {}
// 按 api_base+admin_key 去重,避免重复请求
const seen = new Map<string, S2AProfile>()
for (const p of profiles) {
if (p.api_base && p.admin_key) {
const key = `${p.api_base}|${p.admin_key}`
if (!seen.has(key)) seen.set(key, p)
}
}
for (const p of seen.values()) {
try {
const groups = await fetchGroupsWithCredentials(p.api_base, p.admin_key)
for (const g of groups) {
cache[g.id] = g.name
}
} catch { /* ignore - will show #id fallback */ }
}
setGroupNameCache(cache)
}
fetchGroupNames()
}, [viewMode, profiles])
// 清除消息定时器 // 清除消息定时器
useEffect(() => { useEffect(() => {
if (!message) return if (!message) return
@@ -77,6 +113,9 @@ export default function S2AConfig() {
setFormProxyEnabled(false) setFormProxyEnabled(false)
setFormProxyAddress('') setFormProxyAddress('')
setTestResult(null) setTestResult(null)
setAvailableGroups([])
setFetchingGroups(false)
setGroupsFetched(false)
} }
// 进入新建模式 // 进入新建模式
@@ -101,6 +140,9 @@ export default function S2AConfig() {
setFormProxyEnabled(profile.proxy_enabled) setFormProxyEnabled(profile.proxy_enabled)
setFormProxyAddress(profile.proxy_address || '') setFormProxyAddress(profile.proxy_address || '')
setTestResult(null) setTestResult(null)
setAvailableGroups([])
setFetchingGroups(false)
setGroupsFetched(false)
setViewMode('edit') setViewMode('edit')
} }
@@ -222,6 +264,27 @@ export default function S2AConfig() {
} }
} }
// 获取分组列表
const handleFetchGroups = async () => {
if (!formApiBase || !formAdminKey) return
setFetchingGroups(true)
try {
const groups = await fetchGroupsWithCredentials(formApiBase, formAdminKey)
setAvailableGroups(groups)
setGroupsFetched(true)
} catch (err) {
setMessage({ type: 'error', text: `获取分组失败: ${err instanceof Error ? err.message : '未知错误'}` })
} finally {
setFetchingGroups(false)
}
}
// 获取分组名称
const getGroupName = (id: number): string => {
const group = availableGroups.find(g => g.id === id)
return group ? group.name : `#${id}`
}
const handleAddGroupId = () => { const handleAddGroupId = () => {
const id = parseInt(newGroupId, 10) const id = parseInt(newGroupId, 10)
if (!isNaN(id) && !formGroupIds.includes(id)) { if (!isNaN(id) && !formGroupIds.includes(id)) {
@@ -339,23 +402,75 @@ export default function S2AConfig() {
<Input label="默认并发数" type="number" min={1} max={100} value={formConcurrency} onChange={(e) => setFormConcurrency(Number(e.target.value))} hint="账号的默认并发请求数" /> <Input label="默认并发数" type="number" min={1} max={100} value={formConcurrency} onChange={(e) => setFormConcurrency(Number(e.target.value))} hint="账号的默认并发请求数" />
<Input label="默认优先级" type="number" min={0} max={100} value={formPriority} onChange={(e) => setFormPriority(Number(e.target.value))} hint="数值越大优先级越高" /> <Input label="默认优先级" type="number" min={0} max={100} value={formPriority} onChange={(e) => setFormPriority(Number(e.target.value))} hint="数值越大优先级越高" />
</div> </div>
<div className="space-y-2"> <div className="space-y-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"> ID</label> {/* 标题 + 获取按钮 */}
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300"></label>
<Button
variant="outline"
onClick={handleFetchGroups}
disabled={fetchingGroups || !formApiBase || !formAdminKey}
icon={fetchingGroups ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
className="h-7 text-xs px-3"
>
{fetchingGroups ? '获取中...' : '获取分组列表'}
</Button>
</div>
{!formApiBase || !formAdminKey ? (
<p className="text-xs text-slate-400"> S2A API Admin Key</p>
) : null}
{/* 已选分组 badges */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{formGroupIds.map(id => ( {formGroupIds.map(id => (
<span key={id} className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"> <span key={id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{id} {groupsFetched ? getGroupName(id) : `#${id}`}
<button onClick={() => handleRemoveGroupId(id)} className="hover:text-red-500 transition-colors"><X className="h-3 w-3" /></button> <button onClick={() => handleRemoveGroupId(id)} className="hover:text-red-500 transition-colors"><X className="h-3 w-3" /></button>
</span> </span>
))} ))}
{formGroupIds.length === 0 && <span className="text-sm text-slate-400"></span>} {formGroupIds.length === 0 && <span className="text-sm text-slate-400"></span>}
</div> </div>
<div className="flex gap-3 mt-2 items-stretch">
<div className="flex-1 max-w-xs"> {/* 可选分组列表 */}
<Input placeholder="输入分组 ID" type="number" min={1} value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()} className="h-10" /> {groupsFetched && availableGroups.length > 0 && (
<div className="border rounded-lg p-3 bg-slate-50 dark:bg-slate-800/50 dark:border-slate-700 space-y-1.5">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">:</p>
<div className="flex flex-wrap gap-2">
{availableGroups
.filter(g => !formGroupIds.includes(g.id))
.map(g => (
<button
key={g.id}
onClick={() => setFormGroupIds([...formGroupIds, g.id])}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 hover:border-blue-400 hover:text-blue-600 dark:hover:border-blue-500 dark:hover:text-blue-400 transition-colors"
>
<Plus className="h-3 w-3" />
{g.name}
<span className="text-xs text-slate-400">#{g.id}</span>
</button>
))}
{availableGroups.filter(g => !formGroupIds.includes(g.id)).length === 0 && (
<span className="text-xs text-slate-400"></span>
)}
</div>
</div> </div>
<Button variant="outline" onClick={handleAddGroupId} disabled={!newGroupId} icon={<Plus className="h-4 w-4" />} className="h-10 min-w-[80px] px-4"></Button> )}
</div> {groupsFetched && availableGroups.length === 0 && (
<p className="text-xs text-slate-400"> S2A </p>
)}
{/* 手动输入 fallback */}
<details className="text-sm">
<summary className="cursor-pointer text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 select-none">
ID
</summary>
<div className="flex gap-3 mt-2 items-stretch">
<div className="flex-1 max-w-xs">
<Input placeholder="输入分组 ID" type="number" min={1} value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()} className="h-10" />
</div>
<Button variant="outline" onClick={handleAddGroupId} disabled={!newGroupId} icon={<Plus className="h-4 w-4" />} className="h-10 min-w-[80px] px-4"></Button>
</div>
</details>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -440,6 +555,7 @@ export default function S2AConfig() {
profile={profile} profile={profile}
isActive={activeApiBase === profile.api_base} isActive={activeApiBase === profile.api_base}
isActivating={activating === profile.id} isActivating={activating === profile.id}
groupNameCache={groupNameCache}
onActivate={() => handleActivate(profile)} onActivate={() => handleActivate(profile)}
onEdit={() => handleEdit(profile)} onEdit={() => handleEdit(profile)}
onDelete={() => handleDelete(profile.id, profile.name)} onDelete={() => handleDelete(profile.id, profile.name)}
@@ -452,10 +568,11 @@ export default function S2AConfig() {
} }
// ==================== 配置卡片组件 ==================== // ==================== 配置卡片组件 ====================
function ProfileCard({ profile, isActive, isActivating, onActivate, onEdit, onDelete }: { function ProfileCard({ profile, isActive, isActivating, groupNameCache, onActivate, onEdit, onDelete }: {
profile: S2AProfile profile: S2AProfile
isActive: boolean isActive: boolean
isActivating: boolean isActivating: boolean
groupNameCache: Record<number, string>
onActivate: () => void onActivate: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
@@ -492,7 +609,7 @@ function ProfileCard({ profile, isActive, isActivating, onActivate, onEdit, onDe
</span> </span>
{parsedGroups.length > 0 && ( {parsedGroups.length > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400"> <span className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400">
{parsedGroups.join(', ')} {parsedGroups.map(id => groupNameCache[id] || `#${id}`).join(', ')}
</span> </span>
)} )}
{profile.proxy_enabled && ( {profile.proxy_enabled && (