Bläddra i källkod

feat: 号池管理功能

- 新增分组-Key号池关联表(一对多关系)
- 新增号池管理API(分组key的增删改查)
- 新增Key健康度检测API(手动检测/批量检测)
- 新增号池推送服务(定时推送到company_base_pool)
- 新增号池管理前端页面(深色主题)
- 爬虫返回数据增加api_keys字段(多个key)
- 前端添加检测全部、添加Key按钮

参考NewAPI实现:
- 状态分类:active/failed/banned
- 连续失败3次自动禁用
- 检测通过自动恢复
mengboxin137-blip 3 veckor sedan
förälder
incheckning
097801d9fc

+ 1 - 1
backend/.env

@@ -7,7 +7,7 @@ DB_USER=crawl_test_pool
 DB_PASSWORD=wsNbzdnmPnpwCj56
 DB_NAME=crawl_test_pool
 
-ALLOWED_ORIGINS=http://localhost:5173
+ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
 # ALLOWED_ORIGINS=https://crawler.aitoolcore.com
 GEOIP_DB_PATH=./GeoLite2-City.mmdb
 #本地(需替换为本地真实路径)

+ 4 - 0
backend/app/main.py

@@ -74,6 +74,8 @@ from app.routers import scrape_stats  # noqa: E402
 from app.routers import domain_model_prices  # noqa: E402
 from app.routers import api_keys  # noqa: E402
 from app.routers import model_groups  # noqa: E402
+from app.routers import group_keys  # noqa: E402
+from app.routers import group_keys_test  # noqa: E402
 app.include_router(stats.router, prefix="/api")
 app.include_router(logs.router, prefix="/api")
 app.include_router(scrape.router, prefix="/api")
@@ -86,6 +88,8 @@ app.include_router(scrape_stats.router, prefix="/api")
 app.include_router(domain_model_prices.router, prefix="/api")
 app.include_router(api_keys.router, prefix="/api")
 app.include_router(model_groups.router, prefix="/api")
+app.include_router(group_keys.router, prefix="/api")
+app.include_router(group_keys_test.router, prefix="/api")
 app.include_router(ws.router)
 
 

+ 379 - 0
backend/app/routers/group_keys.py

@@ -0,0 +1,379 @@
+"""
+分组-Key号池管理路由
+支持一个分组绑定多个key,形成号池
+"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from app.db import get_pool
+
+router = APIRouter(tags=["group_keys"])
+
+
+class GroupKeyIn(BaseModel):
+    """添加key到分组"""
+    group_id: int
+    api_key_id: int
+    priority: Optional[int] = 0
+    weight: Optional[int] = 1
+
+
+class GroupKeyUpdate(BaseModel):
+    """更新分组-key关系"""
+    priority: Optional[int] = None
+    weight: Optional[int] = None
+    is_active: Optional[bool] = None
+    status: Optional[str] = None
+    status_msg: Optional[str] = None
+
+
+class GroupKeyOut(BaseModel):
+    id: int
+    group_id: int
+    group_name: str
+    api_key_id: int
+    api_key_name: str
+    api_key_masked: str
+    priority: int
+    weight: int
+    is_active: bool
+    status: str
+    status_msg: Optional[str]
+    last_used_at: Optional[datetime]
+    total_tokens: int
+    request_count: int
+    error_count: int
+    created_at: datetime
+    updated_at: datetime
+
+
+class BatchAddKeysIn(BaseModel):
+    """批量添加key到分组"""
+    group_id: int
+    api_key_ids: List[int]
+    priority: Optional[int] = 0
+    weight: Optional[int] = 1
+
+
+def _mask_key(v: str) -> str:
+    if len(v) <= 10:
+        return '••••••••'
+    return v[:6] + '••••••••' + v[-4:]
+
+
+@router.get("/group-keys", response_model=List[GroupKeyOut])
+async def list_group_keys(group_id: Optional[int] = None) -> List[GroupKeyOut]:
+    """获取分组-key列表"""
+    pool = get_pool()
+
+    query = """
+        SELECT gk.id, gk.group_id, mg.name AS group_name,
+               gk.api_key_id, ak.name AS api_key_name, ak.key_value,
+               gk.priority, gk.weight, gk.is_active, gk.status, gk.status_msg,
+               gk.last_used_at, gk.total_tokens, gk.request_count, gk.error_count,
+               gk.created_at, gk.updated_at
+        FROM group_keys gk
+        JOIN model_groups mg ON mg.id = gk.group_id
+        JOIN api_keys ak ON ak.id = gk.api_key_id
+    """
+
+    if group_id is not None:
+        query += " WHERE gk.group_id = $1"
+        rows = await pool.fetch(query, group_id)
+    else:
+        rows = await pool.fetch(query)
+
+    return [
+        GroupKeyOut(**{
+            **dict(r),
+            "api_key_masked": _mask_key(r["key_value"])
+        })
+        for r in rows
+    ]
+
+
+@router.post("/group-keys", response_model=GroupKeyOut, status_code=201)
+async def add_key_to_group(body: GroupKeyIn) -> GroupKeyOut:
+    """添加key到分组(号池)"""
+    pool = get_pool()
+
+    # 检查分组是否存在
+    group = await pool.fetchrow("SELECT id, name FROM model_groups WHERE id = $1", body.group_id)
+    if not group:
+        raise HTTPException(status_code=404, detail="分组不存在")
+
+    # 检查key是否存在
+    key = await pool.fetchrow("SELECT id, name, key_value FROM api_keys WHERE id = $1", body.api_key_id)
+    if not key:
+        raise HTTPException(status_code=404, detail="API Key不存在")
+
+    # 检查是否已存在
+    existing = await pool.fetchrow(
+        "SELECT id FROM group_keys WHERE group_id = $1 AND api_key_id = $2",
+        body.group_id, body.api_key_id
+    )
+    if existing:
+        raise HTTPException(status_code=400, detail="该key已在此分组中")
+
+    row = await pool.fetchrow(
+        """
+        INSERT INTO group_keys (group_id, api_key_id, priority, weight)
+        VALUES ($1, $2, $3, $4)
+        RETURNING id, group_id, api_key_id, priority, weight, is_active, status, status_msg,
+                  last_used_at, total_tokens, request_count, error_count, created_at, updated_at
+        """,
+        body.group_id, body.api_key_id, body.priority or 0, body.weight or 1
+    )
+
+    return GroupKeyOut(**{
+        **dict(row),
+        "group_name": group["name"],
+        "api_key_name": key["name"],
+        "api_key_masked": _mask_key(key["key_value"]),
+    })
+
+
+@router.post("/group-keys/batch-add", response_model=List[GroupKeyOut], status_code=201)
+async def batch_add_keys_to_group(body: BatchAddKeysIn) -> List[GroupKeyOut]:
+    """批量添加key到分组"""
+    pool = get_pool()
+
+    # 检查分组
+    group = await pool.fetchrow("SELECT id, name FROM model_groups WHERE id = $1", body.group_id)
+    if not group:
+        raise HTTPException(status_code=404, detail="分组不存在")
+
+    results = []
+    for key_id in body.api_key_ids:
+        key = await pool.fetchrow("SELECT id, name, key_value FROM api_keys WHERE id = $1", key_id)
+        if not key:
+            continue
+
+        # 检查是否已存在
+        existing = await pool.fetchrow(
+            "SELECT id FROM group_keys WHERE group_id = $1 AND api_key_id = $2",
+            body.group_id, key_id
+        )
+        if existing:
+            continue
+
+        row = await pool.fetchrow(
+            """
+            INSERT INTO group_keys (group_id, api_key_id, priority, weight)
+            VALUES ($1, $2, $3, $4)
+            RETURNING id, group_id, api_key_id, priority, weight, is_active, status, status_msg,
+                      last_used_at, total_tokens, request_count, error_count, created_at, updated_at
+            """,
+            body.group_id, key_id, body.priority or 0, body.weight or 1
+        )
+
+        results.append(GroupKeyOut(**{
+            **dict(row),
+            "group_name": group["name"],
+            "api_key_name": key["name"],
+            "api_key_masked": _mask_key(key["key_value"]),
+        }))
+
+    return results
+
+
+@router.put("/group-keys/{id}", response_model=GroupKeyOut)
+async def update_group_key(id: int, body: GroupKeyUpdate) -> GroupKeyOut:
+    """更新分组-key关系"""
+    pool = get_pool()
+
+    existing = await pool.fetchrow(
+        """
+        SELECT gk.*, mg.name AS group_name, ak.name AS api_key_name, ak.key_value
+        FROM group_keys gk
+        JOIN model_groups mg ON mg.id = gk.group_id
+        JOIN api_keys ak ON ak.id = gk.api_key_id
+        WHERE gk.id = $1
+        """,
+        id
+    )
+    if not existing:
+        raise HTTPException(status_code=404, detail="记录不存在")
+
+    updates = []
+    values = []
+    param_idx = 1
+
+    if body.priority is not None:
+        updates.append(f"priority = ${param_idx}")
+        values.append(body.priority)
+        param_idx += 1
+    if body.weight is not None:
+        updates.append(f"weight = ${param_idx}")
+        values.append(body.weight)
+        param_idx += 1
+    if body.is_active is not None:
+        updates.append(f"is_active = ${param_idx}")
+        values.append(body.is_active)
+        param_idx += 1
+    if body.status is not None:
+        updates.append(f"status = ${param_idx}")
+        values.append(body.status)
+        param_idx += 1
+    if body.status_msg is not None:
+        updates.append(f"status_msg = ${param_idx}")
+        values.append(body.status_msg)
+        param_idx += 1
+
+    if updates:
+        updates.append("updated_at = NOW()")
+        values.append(id)
+        await pool.execute(
+            f"UPDATE group_keys SET {', '.join(updates)} WHERE id = ${param_idx}",
+            *values
+        )
+
+    # 重新获取
+    row = await pool.fetchrow(
+        """
+        SELECT gk.id, gk.group_id, mg.name AS group_name,
+               gk.api_key_id, ak.name AS api_key_name, ak.key_value,
+               gk.priority, gk.weight, gk.is_active, gk.status, gk.status_msg,
+               gk.last_used_at, gk.total_tokens, gk.request_count, gk.error_count,
+               gk.created_at, gk.updated_at
+        FROM group_keys gk
+        JOIN model_groups mg ON mg.id = gk.group_id
+        JOIN api_keys ak ON ak.id = gk.api_key_id
+        WHERE gk.id = $1
+        """,
+        id
+    )
+
+    return GroupKeyOut(**{
+        **dict(row),
+        "api_key_masked": _mask_key(row["key_value"])
+    })
+
+
+@router.delete("/group-keys/{id}", status_code=204)
+async def remove_key_from_group(id: int):
+    """从分组中移除key"""
+    pool = get_pool()
+    result = await pool.execute("DELETE FROM group_keys WHERE id = $1", id)
+    if result == "DELETE 0":
+        raise HTTPException(status_code=404, detail="记录不存在")
+
+
+@router.get("/groups/{group_id}/pool-summary")
+async def get_group_pool_summary(group_id: int):
+    """获取分组号池摘要(健康度统计)"""
+    pool = get_pool()
+
+    # 检查分组
+    group = await pool.fetchrow("SELECT id, name FROM model_groups WHERE id = $1", group_id)
+    if not group:
+        raise HTTPException(status_code=404, detail="分组不存在")
+
+    # 统计
+    stats = await pool.fetchrow(
+        """
+        SELECT
+            COUNT(*) AS total_keys,
+            COUNT(*) FILTER (WHERE status = 'active' AND is_active = TRUE) AS active_keys,
+            COUNT(*) FILTER (WHERE status = 'rate_limited') AS rate_limited_keys,
+            COUNT(*) FILTER (WHERE status = 'banned') AS banned_keys,
+            COUNT(*) FILTER (WHERE status = 'failed') AS failed_keys,
+            SUM(total_tokens) AS total_tokens,
+            SUM(request_count) AS total_requests,
+            SUM(error_count) AS total_errors
+        FROM group_keys
+        WHERE group_id = $1
+        """,
+        group_id
+    )
+
+    # 健康度计算
+    total = stats["total_keys"] or 0
+    active = stats["active_keys"] or 0
+    health_rate = (active / total * 100) if total > 0 else 0
+
+    return {
+        "group_id": group_id,
+        "group_name": group["name"],
+        "total_keys": total,
+        "active_keys": active,
+        "rate_limited_keys": stats["rate_limited_keys"] or 0,
+        "banned_keys": stats["banned_keys"] or 0,
+        "failed_keys": stats["failed_keys"] or 0,
+        "health_rate": round(health_rate, 2),
+        "total_tokens": stats["total_tokens"] or 0,
+        "total_requests": stats["total_requests"] or 0,
+        "total_errors": stats["total_errors"] or 0,
+    }
+
+
+@router.get("/groups/{group_id}/keys")
+async def get_group_keys_detail(group_id: int):
+    """获取分组的key详情列表"""
+    pool = get_pool()
+
+    rows = await pool.fetch(
+        """
+        SELECT gk.id, gk.api_key_id, ak.name AS api_key_name, ak.key_value,
+               gk.priority, gk.weight, gk.is_active, gk.status, gk.status_msg,
+               gk.last_used_at, gk.total_tokens, gk.request_count, gk.error_count,
+               gk.created_at, gk.updated_at
+        FROM group_keys gk
+        JOIN api_keys ak ON ak.id = gk.api_key_id
+        WHERE gk.group_id = $1
+        ORDER BY gk.priority DESC, gk.weight DESC
+        """,
+        group_id
+    )
+
+    return [
+        {
+            "id": r["id"],
+            "api_key_id": r["api_key_id"],
+            "api_key_name": r["api_key_name"],
+            "api_key_masked": _mask_key(r["key_value"]),
+            "priority": r["priority"],
+            "weight": r["weight"],
+            "is_active": r["is_active"],
+            "status": r["status"],
+            "status_msg": r["status_msg"],
+            "last_used_at": r["last_used_at"].isoformat() if r["last_used_at"] else None,
+            "total_tokens": r["total_tokens"],
+            "request_count": r["request_count"],
+            "error_count": r["error_count"],
+            "created_at": r["created_at"].isoformat() if r["created_at"] else None,
+            "updated_at": r["updated_at"].isoformat() if r["updated_at"] else None,
+        }
+        for r in rows
+    ]
+
+
+# ============================================
+# 推送到company_base_pool
+# ============================================
+
+@router.post("/groups/{group_id}/push")
+async def push_group(group_id: int):
+    """手动推送单个分组到company_base_pool"""
+    pool = get_pool()
+    try:
+        await push_group_keys_to_pool(pool, group_id, [])
+        return {"success": True, "message": "推送成功"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"推送失败: {str(e)}")
+
+
+@router.post("/groups/push-all")
+async def push_all_groups():
+    """手动推送所有分组到company_base_pool"""
+    pool = get_pool()
+    try:
+        await push_all_groups_to_pool(pool)
+        return {"success": True, "message": "推送完成"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"推送失败: {str(e)}")

+ 215 - 0
backend/app/routers/group_keys_test.py

@@ -0,0 +1,215 @@
+"""
+Key健康度检测路由
+手动检测key是否可用,更新状态
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from datetime import datetime
+
+import httpx
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from app.db import get_pool
+
+router = APIRouter(tags=["group_keys_test"])
+
+logger = logging.getLogger(__name__)
+
+# 检测配置
+TEST_TIMEOUT = 10  # 超时时间(秒)
+MAX_CONSECUTIVE_FAILURES = 3  # 连续失败多少次后禁用
+
+
+class TestResult(BaseModel):
+    success: bool
+    status: str  # active / rate_limited / failed / banned
+    error: str | None = None
+    latency_ms: int = 0
+
+
+class TestAllResult(BaseModel):
+    total: int
+    active: int
+    failed: int
+    banned: int
+    health_rate: float
+
+
+async def _test_key(api_key: str, base_url: str, provider: str) -> tuple[bool, str]:
+    """测试key是否可用"""
+    try:
+        async with httpx.AsyncClient(timeout=TEST_TIMEOUT) as client:
+            # 根据provider选择不同的测试方式
+            if provider == "openai":
+                url = f"{base_url.rstrip('/')}/v1/chat/completions"
+                headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+                data = {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 5}
+                response = await client.post(url, headers=headers, json=data)
+            elif provider == "dashscope":
+                url = f"{base_url.rstrip('/')}/compatible-mode/v1/chat/completions"
+                headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+                data = {"model": "qwen-turbo", "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 5}
+                response = await client.post(url, headers=headers, json=data)
+            else:
+                # 默认OpenAI兼容
+                url = f"{base_url.rstrip('/')}/v1/chat/completions"
+                headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+                data = {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "Hi"}], "max_tokens": 5}
+                response = await client.post(url, headers=headers, json=data)
+
+            status_code = response.status_code
+
+            if status_code in [200, 201]:
+                return True, ""
+            elif status_code == 429:
+                return False, "429_rate_limited"
+            elif status_code in [401, 403]:
+                return False, f"{status_code}_invalid_key"
+            elif status_code == 400:
+                try:
+                    error_data = response.json()
+                    error_msg = error_data.get('error', {}).get('message', '')
+                    if 'model' in error_msg.lower():
+                        return True, ""  # 模型名错误但key可能是好的
+                except:
+                    pass
+                return False, "400_bad_request"
+            elif status_code in [500, 502, 503, 504]:
+                return False, f"{status_code}_server_error"
+            else:
+                return False, f"{status_code}_unknown"
+
+    except httpx.TimeoutException:
+        return False, "timeout"
+    except httpx.ConnectError as e:
+        return False, f"network: {str(e)[:50]}"
+    except Exception as e:
+        return False, f"unknown: {str(e)[:50]}"
+
+
+@router.post("/group-keys/{key_id}/test")
+async def test_key(key_id: int):
+    """检测单个key的健康状态"""
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        # 获取key信息
+        row = await conn.fetchrow("""
+            SELECT gk.id, gk.api_key_id, gk.group_id, gk.status, gk.is_active,
+                   ak.key_value, ak.name as key_name,
+                   mg.name as group_name
+            FROM group_keys gk
+            JOIN api_keys ak ON ak.id = gk.api_key_id
+            JOIN model_groups mg ON mg.id = gk.group_id
+            WHERE gk.id = $1
+        """, key_id)
+
+        if not row:
+            raise HTTPException(status_code=404, detail="Key不存在")
+
+        # 获取分组的provider(从模型推断)
+        provider = "dashscope"  # 默认
+        base_url = "https://dashscope.aliyuncs.com"  # DashScope默认URL
+
+        # 测试key
+        start_time = time.time()
+        is_healthy, error_type = await _test_key(row['key_value'], base_url, provider)
+        end_time = time.time()
+        latency_ms = int((end_time - start_time) * 1000)
+
+        # 更新状态
+        if is_healthy:
+            new_status = "active"
+            await conn.execute("""
+                UPDATE group_keys
+                SET status = 'active', status_msg = NULL, updated_at = NOW()
+                WHERE id = $1
+            """, key_id)
+        else:
+            if error_type == "429_rate_limited":
+                new_status = "rate_limited"
+            else:
+                new_status = "failed"
+
+            await conn.execute("""
+                UPDATE group_keys
+                SET status = $1, status_msg = $2, updated_at = NOW()
+                WHERE id = $3
+            """, new_status, error_type, key_id)
+
+        return TestResult(
+            success=is_healthy,
+            status=new_status,
+            error=error_type if not is_healthy else None,
+            latency_ms=latency_ms,
+        )
+
+
+@router.post("/groups/{group_id}/test-all")
+async def test_all_keys(group_id: int):
+    """批量检测分组下所有key"""
+    pool = get_pool()
+    async with pool.acquire() as conn:
+        # 获取分组下所有key
+        rows = await conn.fetch("""
+            SELECT gk.id, gk.api_key_id, gk.status, gk.is_active,
+                   ak.key_value
+            FROM group_keys gk
+            JOIN api_keys ak ON ak.id = gk.api_key_id
+            WHERE gk.group_id = $1 AND gk.is_active = TRUE
+        """, group_id)
+
+        if not rows:
+            return TestAllResult(total=0, active=0, failed=0, banned=0, health_rate=0)
+
+        total = len(rows)
+        active = 0
+        failed = 0
+        banned = 0
+
+        provider = "dashscope"
+        base_url = "https://dashscope.aliyuncs.com"
+
+        for row in rows:
+            try:
+                start_time = time.time()
+                is_healthy, error_type = await _test_key(row['key_value'], base_url, provider)
+                end_time = time.time()
+                latency_ms = int((end_time - start_time) * 1000)
+
+                if is_healthy:
+                    active += 1
+                    await conn.execute("""
+                        UPDATE group_keys
+                        SET status = 'active', status_msg = NULL, updated_at = NOW()
+                        WHERE id = $1
+                    """, row['id'])
+                else:
+                    failed += 1
+                    status = "rate_limited" if error_type == "429_rate_limited" else "failed"
+                    await conn.execute("""
+                        UPDATE group_keys
+                        SET status = $1, status_msg = $2, updated_at = NOW()
+                        WHERE id = $3
+                    """, status, error_type, row['id'])
+
+            except Exception as e:
+                failed += 1
+                await conn.execute("""
+                    UPDATE group_keys
+                    SET status = 'failed', status_msg = $1, updated_at = NOW()
+                    WHERE id = $2
+                """, str(e)[:100], row['id'])
+
+        health_rate = (active / total * 100) if total > 0 else 0
+
+        return TestAllResult(
+            total=total,
+            active=active,
+            failed=failed,
+            banned=banned,
+            health_rate=round(health_rate, 2),
+        )

+ 24 - 4
backend/app/routers/public.py

@@ -28,7 +28,8 @@ class PublicPriceOut(BaseModel):
     icon: Optional[str] = None
     scraped_at: datetime
     discount: Optional[float] = None
-    api_key: Optional[str] = None
+    api_key: Optional[str] = None  # 向后兼容:保留单个key
+    api_keys: Optional[List[str]] = None  # 新增:号池多个key(来自分组的group_keys表)
     group_name: Optional[str] = None
 
 
@@ -156,18 +157,28 @@ async def get_public_prices(
     if version != 0 and version == current_version:
         return UpToDateResponse(up_to_date=True, version=current_version)
 
-    # 从 price_snapshot 读取数据,LEFT JOIN models 取 api_key 和 group
+    # 从 price_snapshot 读取数据,LEFT JOIN models 取 group
+    # 新增:从 group_keys 获取分组的多个key(号池)
     if url is None:
         rows = await pool.fetch(
             """
             SELECT ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
                    ps.tool_prices, ps.icon, ps.updated_at,
                    k.key_value AS api_key,
-                   m.group_id, g.name AS group_name
+                   m.group_id, g.name AS group_name,
+                   -- 获取分组号池的所有key(聚合为数组)
+                   COALESCE(
+                       ARRAY_AGG(gk_keys.key_value) FILTER (WHERE gk_keys.key_value IS NOT NULL),
+                       ARRAY[]::text[]
+                   ) AS group_api_keys
             FROM price_snapshot ps
             LEFT JOIN models m ON m.url = ps.url
             LEFT JOIN api_keys k ON k.id = m.api_key_id
             LEFT JOIN model_groups g ON g.id = m.group_id
+            LEFT JOIN group_keys gk ON gk.group_id = m.group_id AND gk.is_active = TRUE AND gk.status = 'active'
+            LEFT JOIN api_keys gk_keys ON gk_keys.id = gk.api_key_id
+            GROUP BY ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
+                     ps.tool_prices, ps.icon, ps.updated_at, k.key_value, m.group_id, g.name
             ORDER BY ps.url
             """
         )
@@ -177,12 +188,20 @@ async def get_public_prices(
             SELECT ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
                    ps.tool_prices, ps.icon, ps.updated_at,
                    k.key_value AS api_key,
-                   m.group_id, g.name AS group_name
+                   m.group_id, g.name AS group_name,
+                   COALESCE(
+                       ARRAY_AGG(gk_keys.key_value) FILTER (WHERE gk_keys.key_value IS NOT NULL),
+                       ARRAY[]::text[]
+                   ) AS group_api_keys
             FROM price_snapshot ps
             LEFT JOIN models m ON m.url = ps.url
             LEFT JOIN api_keys k ON k.id = m.api_key_id
             LEFT JOIN model_groups g ON g.id = m.group_id
+            LEFT JOIN group_keys gk ON gk.group_id = m.group_id AND gk.is_active = TRUE AND gk.status = 'active'
+            LEFT JOIN api_keys gk_keys ON gk_keys.id = gk.api_key_id
             WHERE ps.url = $1
+            GROUP BY ps.url, ps.model_name, ps.prices, ps.model_info, ps.rate_limits,
+                     ps.tool_prices, ps.icon, ps.updated_at, k.key_value, m.group_id, g.name
             """,
             url,
         )
@@ -215,6 +234,7 @@ async def get_public_prices(
         scraped_at=r["updated_at"],
         discount=model_custom_prices.get(r["model_name"]) if model_custom_prices.get(r["model_name"]) is not None else discount_rate,
         api_key=try_encrypt(r["api_key"]),
+        api_keys=[try_encrypt(k) for k in r["group_api_keys"]] if r["group_api_keys"] else None,  # 号池key列表
         group_name=r["group_name"],
     ) for r in rows]
 

+ 103 - 0
backend/app/services/key_pool_pusher.py

@@ -0,0 +1,103 @@
+"""
+号池数据推送服务
+定时将分组和key数据推送到company_base_pool
+"""
+import logging
+import asyncio
+from datetime import datetime
+import httpx
+import os
+
+logger = logging.getLogger(__name__)
+
+# company_base_pool的API地址
+COMPANY_BASE_POOL_URL = os.getenv("COMPANY_BASE_POOL_URL", "http://localhost:8011")
+SYNC_ENDPOINT = f"{COMPANY_BASE_POOL_URL}/api/admin/key-pool/sync"
+
+
+async def push_group_keys_to_pool(pool, group_id: int, api_key_ids: list):
+    """
+    推送分组的key到company_base_pool
+
+    Args:
+        pool: 数据库连接池
+        group_id: 分组ID
+        api_key_ids: key ID列表
+    """
+    try:
+        async with pool.acquire() as conn:
+            # 设置search_path
+            await conn.execute("SET search_path TO crawl")
+
+            # 获取分组信息
+            group = await conn.fetchrow("SELECT name FROM model_groups WHERE id = $1", group_id)
+            if not group:
+                return
+
+            # 获取keys
+            keys = await conn.fetch("""
+                SELECT ak.key_value
+                FROM api_keys ak
+                JOIN group_keys gk ON gk.api_key_id = ak.id
+                WHERE gk.group_id = $1 AND gk.is_active = TRUE
+            """, group_id)
+
+            if not keys:
+                return
+
+            api_keys = [k['key_value'] for k in keys]
+
+            # 获取分组的provider(从绑定的模型推断)
+            provider_row = await conn.fetchrow("""
+                SELECT DISTINCT m.provider
+                FROM models m
+                WHERE m.group_id = $1 AND m.provider IS NOT NULL
+                LIMIT 1
+            """, group_id)
+            provider = provider_row['provider'] if provider_row else "dashscope"
+
+            # 推送到company_base_pool
+            data = [{
+                "group_name": group['name'],
+                "provider": provider,
+                "model_name": None,
+                "api_keys": api_keys
+            }]
+
+            async with httpx.AsyncClient(timeout=30) as client:
+                response = await client.post(SYNC_ENDPOINT, json=data)
+
+                if response.status_code == 200:
+                    result = response.json()
+                    logger.info(f"推送分组 {group['name']} 到号池: {result}")
+                else:
+                    logger.error(f"推送失败: {response.status_code} {response.text}")
+
+    except Exception as e:
+        logger.error(f"推送分组 {group_id} 失败: {e}")
+
+
+async def push_all_groups_to_pool(pool):
+    """推送所有分组到company_base_pool"""
+    try:
+        async with pool.acquire() as conn:
+            # 设置search_path
+            await conn.execute("SET search_path TO crawl")
+
+            # 获取所有有key的分组
+            groups = await conn.fetch("""
+                SELECT DISTINCT g.id, g.name
+                FROM model_groups g
+                JOIN group_keys gk ON gk.group_id = g.id
+                WHERE gk.is_active = TRUE
+            """)
+
+            logger.info(f"开始推送 {len(groups)} 个分组到号池")
+
+            for group in groups:
+                await push_group_keys_to_pool(pool, group['id'], [])
+
+            logger.info("推送完成")
+
+    except Exception as e:
+        logger.error(f"推送所有分组失败: {e}")

+ 19 - 1
backend/app/services/scheduler.py

@@ -10,11 +10,13 @@ from apscheduler.triggers.interval import IntervalTrigger
 
 from app.db import get_pool
 from app.services.scraper import ScraperService
+from app.services.key_pool_pusher import push_all_groups_to_pool
 
 logger = logging.getLogger(__name__)
 _scheduler = AsyncIOScheduler()
 _scraper = ScraperService()
 JOB_ID = "auto_scrape"
+JOB_ID_POOL_PUSH = "pool_push"
 
 
 async def _run_auto_scrape() -> None:
@@ -68,8 +70,24 @@ async def start_scheduler() -> None:
     interval = cfg["interval_days"] if cfg else 1
     start_hour = cfg["start_hour"] if cfg else 2
     _reschedule(interval, start_hour)
+
+    # 添加号池推送定时任务(每5分钟推送一次)
+    _scheduler.add_job(
+        _run_pool_push,
+        trigger=IntervalTrigger(minutes=5),
+        id=JOB_ID_POOL_PUSH,
+        replace_existing=True,
+    )
+
     _scheduler.start()
-    logger.info("[scheduler] 调度器已启动")
+    logger.info("[scheduler] 调度器已启动(包含号池推送,每5分钟)")
+
+
+async def _run_pool_push() -> None:
+    """定时推送号池数据"""
+    pool = get_pool()
+    logger.info("[scheduler] 开始推送号池数据到company_base_pool")
+    await push_all_groups_to_pool(pool)
 
 
 async def stop_scheduler() -> None:

+ 31 - 0
backend/migrations/018_group_keys_pool.sql

@@ -0,0 +1,31 @@
+-- Migration 018: 分组-Key 号池(一对多关系)
+-- 一个分组可以有多个key,形成号池
+SET search_path TO crawl;
+
+-- 分组-Key关联表
+CREATE TABLE IF NOT EXISTS group_keys (
+    id          BIGSERIAL    PRIMARY KEY,
+    group_id    BIGINT       NOT NULL REFERENCES model_groups(id) ON DELETE CASCADE,
+    api_key_id  BIGINT       NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
+    priority    INT          NOT NULL DEFAULT 0,        -- 优先级(越大越高)
+    weight      INT          NOT NULL DEFAULT 1,        -- 权重(负载均衡用)
+    is_active   BOOLEAN      NOT NULL DEFAULT TRUE,     -- 是否启用
+    status      VARCHAR(20)  NOT NULL DEFAULT 'active', -- active/rate_limited/banned/failed
+    status_msg  TEXT,                                   -- 状态说明
+    last_used_at TIMESTAMPTZ,                           -- 最后使用时间
+    total_tokens BIGINT      NOT NULL DEFAULT 0,        -- 该key在该分组下使用的token总数
+    request_count BIGINT     NOT NULL DEFAULT 0,        -- 请求次数
+    error_count  BIGINT      NOT NULL DEFAULT 0,        -- 错误次数
+    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
+    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
+    UNIQUE(group_id, api_key_id)  -- 同一分组不能重复添加同一个key
+);
+
+CREATE INDEX IF NOT EXISTS idx_group_keys_group ON group_keys(group_id);
+CREATE INDEX IF NOT EXISTS idx_group_keys_key ON group_keys(api_key_id);
+CREATE INDEX IF NOT EXISTS idx_group_keys_status ON group_keys(status);
+
+COMMENT ON TABLE group_keys IS '分组-Key号池关系表,一个分组可以有多个key';
+COMMENT ON COLUMN group_keys.priority IS '优先级,数值越大越优先使用';
+COMMENT ON COLUMN group_keys.weight IS '权重,用于加权负载均衡';
+COMMENT ON COLUMN group_keys.status IS '状态: active=正常, rate_limited=限流, banned=禁用, failed=失败';

+ 3 - 1
frontend/.env

@@ -1,4 +1,6 @@
 #测试
 VITE_API_BASE_URL=http://localhost:8000
+VITE_COMPANY_BASE_POOL_API=http://localhost:8011
 #生产
-# VITE_API_BASE_URL=https://crawler-api.aitoolcore.com
+# VITE_API_BASE_URL=https://crawler-api.aitoolcore.com
+# VITE_COMPANY_BASE_POOL_API=https://aigc-api.wangxunai.com

+ 3 - 3
frontend/package-lock.json

@@ -501,9 +501,9 @@
       }
     },
     "node_modules/@emnapi/wasi-threads": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
-      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
       "license": "MIT",
       "optional": true,
       "dependencies": {

+ 2 - 0
frontend/src/App.tsx

@@ -9,6 +9,7 @@ import { Logs } from './pages/Logs';
 import { MapPage } from './pages/Map';
 import { ApiKeys } from './pages/ApiKeys';
 import { ModelGroups } from './pages/ModelGroups';
+import { GroupKeys } from './pages/GroupKeys';
 import { Models } from './pages/Models';
 import { ScrapeDashboard } from './pages/ScrapeDashboard';
 import { Scraper } from './pages/Scraper';
@@ -31,6 +32,7 @@ export default function App() {
                   <Route path="/models" element={<Models />} />
                   <Route path="/api-keys" element={<ApiKeys />} />
                   <Route path="/model-groups" element={<ModelGroups />} />
+                  <Route path="/group-keys" element={<GroupKeys />} />
                   <Route path="/scraper" element={<Scraper />} />
                   <Route path="/scrape-dashboard" element={<ScrapeDashboard />} />
                   <Route path="/discounts" element={<Discounts />} />

+ 114 - 0
frontend/src/api.ts

@@ -296,3 +296,117 @@ export async function batchAssignModelGroup(group_id: number | null, model_ids:
   if (!res.ok) throw new Error(`批量分组失败: ${res.status}`);
   return res.json() as Promise<{ updated: number }>;
 }
+
+// ── Group Keys (号池管理) ─────────────────────────────────────────────────────
+
+export interface GroupKey {
+  id: number;
+  group_id: number;
+  group_name: string;
+  api_key_id: number;
+  api_key_name: string;
+  api_key_masked: string;
+  priority: number;
+  weight: number;
+  is_active: boolean;
+  status: string;
+  status_msg: string | null;
+  last_used_at: string | null;
+  total_tokens: number;
+  request_count: number;
+  error_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface GroupPoolSummary {
+  group_id: number;
+  group_name: string;
+  total_keys: number;
+  active_keys: number;
+  rate_limited_keys: number;
+  banned_keys: number;
+  failed_keys: number;
+  health_rate: number;
+  total_tokens: number;
+  total_requests: number;
+  total_errors: number;
+}
+
+export const fetchGroupKeys = (group_id?: number) =>
+  get<GroupKey[]>(`/api/group-keys${group_id ? `?group_id=${group_id}` : ''}`);
+
+export const fetchGroupPoolSummary = (group_id: number) =>
+  get<GroupPoolSummary>(`/api/groups/${group_id}/pool-summary`);
+
+export const fetchGroupKeysDetail = (group_id: number) =>
+  get<GroupKey[]>(`/api/groups/${group_id}/keys`);
+
+// 手动推送到company_base_pool
+export async function pushGroupToPool(group_id: number): Promise<{ success: boolean; message: string }> {
+  const res = await fetch(`${BASE}/api/groups/${group_id}/push`, {
+    method: 'POST',
+  });
+  if (!res.ok) throw new Error(`推送失败: ${res.status}`);
+  return res.json();
+}
+
+export async function pushAllGroupsToPool(): Promise<{ success: boolean; message: string }> {
+  const res = await fetch(`${BASE}/api/groups/push-all`, {
+    method: 'POST',
+  });
+  if (!res.ok) throw new Error(`推送失败: ${res.status}`);
+  return res.json();
+}
+
+// 手动检测key健康状态
+export async function testGroupKey(keyId: number): Promise<{ success: boolean; status: string; error?: string }> {
+  const res = await fetch(`${BASE}/api/group-keys/${keyId}/test`, {
+    method: 'POST',
+  });
+  if (!res.ok) throw new Error(`检测失败: ${res.status}`);
+  return res.json();
+}
+
+export async function testAllGroupKeys(groupId: number): Promise<{ total: number; active: number; failed: number }> {
+  const res = await fetch(`${BASE}/api/groups/${groupId}/test-all`, {
+    method: 'POST',
+  });
+  if (!res.ok) throw new Error(`批量检测失败: ${res.status}`);
+  return res.json();
+}
+
+export async function addKeyToGroup(group_id: number, api_key_id: number, priority?: number, weight?: number): Promise<GroupKey> {
+  const res = await fetch(`${BASE}/api/group-keys`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ group_id, api_key_id, priority: priority ?? 0, weight: weight ?? 1 }),
+  });
+  if (!res.ok) throw new Error(`添加失败: ${res.status}`);
+  return res.json() as Promise<GroupKey>;
+}
+
+export async function batchAddKeysToGroup(group_id: number, api_key_ids: number[], priority?: number, weight?: number): Promise<GroupKey[]> {
+  const res = await fetch(`${BASE}/api/group-keys/batch-add`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ group_id, api_key_ids, priority: priority ?? 0, weight: weight ?? 1 }),
+  });
+  if (!res.ok) throw new Error(`批量添加失败: ${res.status}`);
+  return res.json() as Promise<GroupKey[]>;
+}
+
+export async function updateGroupKey(id: number, data: { priority?: number; weight?: number; is_active?: boolean; status?: string; status_msg?: string | null }): Promise<GroupKey> {
+  const res = await fetch(`${BASE}/api/group-keys/${id}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  if (!res.ok) throw new Error(`更新失败: ${res.status}`);
+  return res.json() as Promise<GroupKey>;
+}
+
+export async function removeKeyFromGroup(id: number): Promise<void> {
+  const res = await fetch(`${BASE}/api/group-keys/${id}`, { method: 'DELETE' });
+  if (!res.ok) throw new Error(`删除失败: ${res.status}`);
+}

+ 1 - 0
frontend/src/components/BottomNav.tsx

@@ -7,6 +7,7 @@ const NAV_ITEMS = [
   { to: '/map', label: '地图', icon: '◎' },
   { to: '/models', label: '模型', icon: '◈' },
   { to: '/model-groups', label: '分组', icon: '▤' },
+  { to: '/group-keys', label: '号池', icon: '🔐' },
   { to: '/api-keys', label: 'API Key', icon: '🔑' },
   { to: '/scraper', label: '爬取', icon: '⟳' },
   { to: '/discounts', label: '折扣', icon: '%' },

+ 429 - 0
frontend/src/pages/GroupKeys.tsx

@@ -0,0 +1,429 @@
+import { useEffect, useState } from 'react';
+import { fetchModelGroups, fetchApiKeys, fetchGroupKeysDetail, fetchGroupPoolSummary, batchAddKeysToGroup, removeKeyFromGroup, updateGroupKey, testGroupKey, testAllGroupKeys } from '../api';
+import type { ModelGroup, ApiKey, GroupKey, GroupPoolSummary } from '../api';
+import './ApiKeys.css';
+
+export function GroupKeys() {
+  const [groups, setGroups] = useState<ModelGroup[]>([]);
+  const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
+  const [selectedGroup, setSelectedGroup] = useState<ModelGroup | null>(null);
+  const [groupKeys, setGroupKeys] = useState<GroupKey[]>([]);
+  const [poolSummary, setPoolSummary] = useState<GroupPoolSummary | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  // 添加key弹窗
+  const [showAdd, setShowAdd] = useState(false);
+  const [selectedKeyIds, setSelectedKeyIds] = useState<number[]>([]);
+  const [addError, setAddError] = useState<string | null>(null);
+  const [adding, setAdding] = useState(false);
+
+  // 检测状态
+  const [testing, setTesting] = useState(false);
+  const [testResult, setTestResult] = useState<{ total: number; active: number; failed: number; health_rate: number } | null>(null);
+
+  const loadGroups = () => {
+    setLoading(true);
+    fetchModelGroups()
+      .then(setGroups)
+      .catch(() => setError('加载分组失败'))
+      .finally(() => setLoading(false));
+  };
+
+  const loadApiKeys = () => {
+    fetchApiKeys()
+      .then(setApiKeys)
+      .catch(() => console.error('加载API Keys失败'));
+  };
+
+  useEffect(() => {
+    loadGroups();
+    loadApiKeys();
+  }, []);
+
+  const selectGroup = async (group: ModelGroup) => {
+    setSelectedGroup(group);
+    setTestResult(null);
+    try {
+      const [keys, summary] = await Promise.all([
+        fetchGroupKeysDetail(group.id),
+        fetchGroupPoolSummary(group.id),
+      ]);
+      setGroupKeys(keys);
+      setPoolSummary(summary);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : String(e));
+    }
+  };
+
+  const handleBatchAdd = async () => {
+    if (!selectedGroup || selectedKeyIds.length === 0) {
+      setAddError('请选择至少一个API Key');
+      return;
+    }
+    setAdding(true);
+    setAddError(null);
+    try {
+      await batchAddKeysToGroup(selectedGroup.id, selectedKeyIds);
+      setShowAdd(false);
+      setSelectedKeyIds([]);
+      selectGroup(selectedGroup);
+    } catch (e) {
+      setAddError(e instanceof Error ? e.message : String(e));
+    } finally {
+      setAdding(false);
+    }
+  };
+
+  const handleRemove = async (id: number) => {
+    if (!confirm('确认从号池中移除该Key?')) return;
+    try {
+      await removeKeyFromGroup(id);
+      if (selectedGroup) selectGroup(selectedGroup);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : String(e));
+    }
+  };
+
+  const handleTestAll = async () => {
+    if (!selectedGroup) return;
+    setTesting(true);
+    setTestResult(null);
+    try {
+      const result = await testAllGroupKeys(selectedGroup.id);
+      setTestResult(result);
+      selectGroup(selectedGroup);
+    } catch (e) {
+      setError(`检测失败: ${e instanceof Error ? e.message : String(e)}`);
+    } finally {
+      setTesting(false);
+    }
+  };
+
+  const handleTestKey = async (key: GroupKey) => {
+    try {
+      const result = await testGroupKey(key.id);
+      setTestResult({
+        total: 1,
+        active: result.success ? 1 : 0,
+        failed: result.success ? 0 : 1,
+        health_rate: result.success ? 100 : 0,
+      });
+      if (selectedGroup) selectGroup(selectedGroup);
+    } catch (e) {
+      setError(`检测失败: ${e instanceof Error ? e.message : String(e)}`);
+    }
+  };
+
+  const handleToggleActive = async (key: GroupKey) => {
+    try {
+      await updateGroupKey(key.id, { is_active: !key.is_active });
+      if (selectedGroup) selectGroup(selectedGroup);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : String(e));
+    }
+  };
+
+  const getStatusBadge = (status: string) => {
+    const styles: Record<string, string> = {
+      active: 'ak-status-active',
+      rate_limited: 'ak-status-warning',
+      failed: 'ak-status-error',
+      banned: 'ak-status-banned',
+    };
+    const labels: Record<string, string> = {
+      active: '正常',
+      rate_limited: '限流',
+      failed: '失败',
+      banned: '禁用',
+    };
+    return <span className={`ak-status-badge ${styles[status] || ''}`}>{labels[status] || status}</span>;
+  };
+
+  const getHealthColor = (rate: number) => {
+    if (rate >= 80) return '#10b981';
+    if (rate >= 50) return '#f59e0b';
+    return '#ef4444';
+  };
+
+  const formatNumber = (num: number) => {
+    if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
+    if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
+    return num.toString();
+  };
+
+  // 已在当前分组的key ids
+  const existingKeyIds = new Set(groupKeys.map(k => k.api_key_id));
+
+  return (
+    <div className="ak-page">
+      <div className="ak-header">
+        <div>
+          <div className="ak-title">号池管理</div>
+          <div className="ak-subtitle">为每个分组配置多个API Key,实现负载均衡和自动轮换</div>
+        </div>
+      </div>
+
+      {error && <div className="ak-error">{error}</div>}
+
+      {testResult && (
+        <div className="gk-test-result" style={{
+          padding: '12px 16px',
+          marginBottom: '16px',
+          borderRadius: '8px',
+          background: testResult.health_rate === 100 ? '#065f46' : testResult.health_rate >= 50 ? '#78350f' : '#7f1d1d',
+          color: '#f3f4f6',
+          display: 'flex',
+          gap: '24px',
+          alignItems: 'center',
+        }}>
+          <span style={{fontWeight: 600}}>检测结果</span>
+          <span>总计: {testResult.total}</span>
+          <span style={{color: '#10b981'}}>正常: {testResult.active}</span>
+          <span style={{color: '#ef4444'}}>失败: {testResult.failed}</span>
+          <span style={{color: testResult.health_rate === 100 ? '#10b981' : testResult.health_rate >= 50 ? '#f59e0b' : '#ef4444', fontWeight: 600}}>健康度: {testResult.health_rate}%</span>
+          <button onClick={() => setTestResult(null)} style={{marginLeft: 'auto', background: 'transparent', border: '1px solid #6b7280', color: '#f3f4f6', padding: '4px 12px', borderRadius: '4px', cursor: 'pointer'}}>关闭</button>
+        </div>
+      )}
+
+      <div className="gk-layout">
+        {/* 左侧:分组列表 */}
+        <div className="gk-sidebar">
+          <div className="gk-sidebar-title">分组列表</div>
+          {groups.map(group => (
+            <div
+              key={group.id}
+              className={`gk-sidebar-item ${selectedGroup?.id === group.id ? 'active' : ''}`}
+              onClick={() => selectGroup(group)}
+            >
+              <div className="gk-sidebar-item-name">{group.name}</div>
+              <div className="gk-sidebar-item-count">{group.model_count} 个模型</div>
+            </div>
+          ))}
+          {groups.length === 0 && !loading && (
+            <div className="gk-empty">暂无分组,请先在「模型分组」中创建</div>
+          )}
+        </div>
+
+        {/* 右侧:号池详情 */}
+        <div className="gk-content">
+          {selectedGroup ? (
+            <>
+              {/* 号池概览 */}
+              {poolSummary && (
+                <div className="gk-summary">
+                  <div className="gk-summary-title">{selectedGroup.name} - 号池概览</div>
+                  <div className="gk-summary-grid">
+                    <div className="gk-summary-item">
+                      <div className="gk-summary-label">总Key数</div>
+                      <div className="gk-summary-value">{poolSummary.total_keys}</div>
+                    </div>
+                    <div className="gk-summary-item">
+                      <div className="gk-summary-label">正常</div>
+                      <div className="gk-summary-value gk-text-green">{poolSummary.active_keys}</div>
+                    </div>
+                    <div className="gk-summary-item">
+                      <div className="gk-summary-label">限流</div>
+                      <div className="gk-summary-value gk-text-yellow">{poolSummary.rate_limited_keys}</div>
+                    </div>
+                    <div className="gk-summary-item">
+                      <div className="gk-summary-label">失败</div>
+                      <div className="gk-summary-value gk-text-red">{poolSummary.failed_keys}</div>
+                    </div>
+                    <div className="gk-summary-item">
+                      <div className="gk-summary-label">健康度</div>
+                      <div className="gk-summary-value" style={{ color: getHealthColor(poolSummary.health_rate) }}>
+                        {poolSummary.health_rate}%
+                      </div>
+                    </div>
+                    <div className="gk-summary-item">
+                      <div className="gk-summary-label">总Token</div>
+                      <div className="gk-summary-value">{formatNumber(poolSummary.total_tokens)}</div>
+                    </div>
+                  </div>
+                </div>
+              )}
+
+              {/* Key列表 */}
+              <div className="gk-keys-header">
+                <span>号池Key列表 ({groupKeys.length})</span>
+                <div style={{display: 'flex', gap: '8px'}}>
+                  <button
+                    className="ak-add-btn"
+                    onClick={handleTestAll}
+                    disabled={testing || groupKeys.length === 0}
+                    style={{background: testing ? '#6b7280' : '#f59e0b'}}
+                  >
+                    {testing ? '检测中...' : ' 检测全部'}
+                  </button>
+                  <button className="ak-add-btn" onClick={() => setShowAdd(true)}>
+                    + 添加Key
+                  </button>
+                </div>
+              </div>
+
+              {groupKeys.length === 0 ? (
+                <div className="gk-empty">该分组暂无Key,点击上方按钮添加</div>
+              ) : (
+                <div className="gk-keys-list">
+                  {groupKeys.map(key => (
+                    <div key={key.id} className={`gk-key-item ${!key.is_active ? 'disabled' : ''}`}>
+                      <div className="gk-key-main">
+                        <div className="gk-key-info">
+                          <span className="gk-key-name">{key.api_key_name}</span>
+                          <code className="gk-key-value">{key.api_key_masked}</code>
+                          {getStatusBadge(key.status)}
+                          {!key.is_active && <span className="ak-status-badge ak-status-disabled">已禁用</span>}
+                        </div>
+                        <div className="gk-key-actions">
+                          <button
+                            className="gk-btn-small"
+                            onClick={() => handleTestKey(key)}
+                            disabled={testing}
+                          >
+                            检测
+                          </button>
+                          <button
+                            className="gk-btn-small"
+                            onClick={() => handleToggleActive(key)}
+                          >
+                            {key.is_active ? '禁用' : '启用'}
+                          </button>
+                          <button
+                            className="gk-btn-small gk-btn-danger"
+                            onClick={() => handleRemove(key.id)}
+                          >
+                            移除
+                          </button>
+                        </div>
+                      </div>
+                      <div className="gk-key-stats">
+                        <span>优先级: {key.priority}</span>
+                        <span>权重: {key.weight}</span>
+                        <span>请求: {formatNumber(key.request_count)}</span>
+                        <span>Token: {formatNumber(key.total_tokens)}</span>
+                        <span>错误: {key.error_count}</span>
+                        {key.last_used_at && (
+                          <span>最后使用: {new Date(key.last_used_at).toLocaleString()}</span>
+                        )}
+                      </div>
+                      {key.status_msg && (
+                        <div className="gk-key-status-msg">状态说明: {key.status_msg}</div>
+                      )}
+                    </div>
+                  ))}
+                </div>
+              )}
+            </>
+          ) : (
+            <div className="gk-empty">请选择一个分组查看号池</div>
+          )}
+        </div>
+      </div>
+
+      {/* 添加Key弹窗 */}
+      {showAdd && (
+        <div className="ak-modal-overlay" onClick={() => setShowAdd(false)}>
+          <div className="ak-modal" onClick={e => e.stopPropagation()}>
+            <div className="ak-modal-header">
+              <span>添加Key到号池</span>
+              <button className="ak-modal-close" onClick={() => setShowAdd(false)}>×</button>
+            </div>
+            <div className="ak-modal-body">
+              {addError && <div className="ak-error">{addError}</div>}
+              <div className="gk-add-keys-list">
+                {apiKeys
+                  .filter(k => !existingKeyIds.has(k.id))
+                  .map(key => (
+                    <label key={key.id} className="gk-add-key-item">
+                      <input
+                        type="checkbox"
+                        checked={selectedKeyIds.includes(key.id)}
+                        onChange={() => {
+                          setSelectedKeyIds(prev =>
+                            prev.includes(key.id)
+                              ? prev.filter(id => id !== key.id)
+                              : [...prev, key.id]
+                          );
+                        }}
+                      />
+                      <span className="gk-add-key-name">{key.name}</span>
+                      <code className="gk-add-key-value">{key.key_value}</code>
+                    </label>
+                  ))}
+                {apiKeys.filter(k => !existingKeyIds.has(k.id)).length === 0 && (
+                  <div className="gk-empty">所有Key都已在此号池中</div>
+                )}
+              </div>
+            </div>
+            <div className="ak-modal-footer">
+              <button className="ak-btn-secondary" onClick={() => setShowAdd(false)}>取消</button>
+              <button className="ak-btn-primary" onClick={handleBatchAdd} disabled={adding}>
+                {adding ? '添加中...' : `添加 ${selectedKeyIds.length} 个Key`}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      <style>{`
+        .gk-layout { display: flex; gap: 20px; min-height: 500px; }
+        .gk-sidebar { width: 240px; border: 1px solid #374151; border-radius: 8px; overflow: hidden; background: #1f2937; }
+        .gk-sidebar-title { padding: 12px 16px; font-weight: 600; background: #111827; border-bottom: 1px solid #374151; color: #f3f4f6; }
+        .gk-sidebar-item { padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #374151; transition: background 0.2s; }
+        .gk-sidebar-item:hover { background: #374151; }
+        .gk-sidebar-item.active { background: #2563eb; border-left: 3px solid #60a5fa; }
+        .gk-sidebar-item-name { font-weight: 500; color: #f3f4f6; }
+        .gk-sidebar-item-count { font-size: 12px; color: #9ca3af; margin-top: 4px; }
+        .gk-content { flex: 1; border: 1px solid #374151; border-radius: 8px; padding: 20px; background: #1f2937; }
+        .gk-summary { margin-bottom: 20px; padding: 16px; background: #111827; border-radius: 8px; }
+        .gk-summary-title { font-weight: 600; margin-bottom: 12px; color: #f3f4f6; }
+        .gk-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; }
+        .gk-summary-item { text-align: center; }
+        .gk-summary-label { font-size: 12px; color: #9ca3af; }
+        .gk-summary-value { font-size: 20px; font-weight: 600; margin-top: 4px; color: #f3f4f6; }
+        .gk-text-green { color: #10b981; }
+        .gk-text-yellow { color: #f59e0b; }
+        .gk-text-red { color: #ef4444; }
+        .gk-keys-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 500; color: #f3f4f6; }
+        .gk-keys-list { display: flex; flex-direction: column; gap: 12px; }
+        .gk-key-item { padding: 16px; border: 1px solid #374151; border-radius: 8px; transition: border-color 0.2s; background: #111827; }
+        .gk-key-item:hover { border-color: #2563eb; }
+        .gk-key-item.disabled { opacity: 0.6; }
+        .gk-key-main { display: flex; justify-content: space-between; align-items: center; }
+        .gk-key-info { display: flex; align-items: center; gap: 12px; }
+        .gk-key-name { font-weight: 500; color: #f3f4f6; }
+        .gk-key-value { font-size: 12px; color: #9ca3af; background: #1f2937; padding: 2px 8px; border-radius: 4px; }
+        .gk-key-actions { display: flex; gap: 8px; }
+        .gk-btn-small { padding: 4px 12px; font-size: 12px; border: 1px solid #374151; border-radius: 4px; background: #1f2937; color: #f3f4f6; cursor: pointer; }
+        .gk-btn-small:hover { background: #374151; }
+        .gk-btn-danger { color: #ef4444; border-color: #7f1d1d; }
+        .gk-btn-danger:hover { background: #7f1d1d; }
+        .gk-key-stats { display: flex; gap: 16px; margin-top: 12px; font-size: 12px; color: #9ca3af; flex-wrap: wrap; }
+        .gk-key-status-msg { margin-top: 8px; font-size: 12px; color: #f59e0b; }
+        .gk-empty { text-align: center; padding: 40px; color: #6b7280; }
+        .ak-status-active { background: #065f46; color: #10b981; }
+        .ak-status-warning { background: #78350f; color: #f59e0b; }
+        .ak-status-error { background: #7f1d1d; color: #ef4444; }
+        .ak-status-banned { background: #1f2937; color: #9ca3af; }
+        .ak-status-disabled { background: #374151; color: #6b7280; }
+        .ak-status-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
+        .gk-add-keys-list { max-height: 400px; overflow-y: auto; }
+        .gk-add-key-item { display: flex; align-items: center; gap: 12px; padding: 12px; border-bottom: 1px solid #374151; cursor: pointer; }
+        .gk-add-key-item:hover { background: #374151; }
+        .gk-add-key-name { font-weight: 500; min-width: 100px; color: #f3f4f6; }
+        .gk-add-key-value { font-size: 12px; color: #9ca3af; }
+        .ak-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
+        .ak-modal { background: #1f2937; border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; }
+        .ak-modal-header { padding: 16px; border-bottom: 1px solid #374151; display: flex; justify-content: space-between; align-items: center; color: #f3f4f6; }
+        .ak-modal-body { padding: 16px; overflow-y: auto; }
+        .ak-modal-footer { padding: 16px; border-top: 1px solid #374151; display: flex; gap: 8px; justify-content: flex-end; }
+        .ak-btn-primary { padding: 8px 16px; background: #2563eb; color: white; border-radius: 4px; border: none; cursor: pointer; }
+        .ak-btn-primary:hover { background: #1d4ed8; }
+        .ak-btn-secondary { padding: 8px 16px; background: #374151; color: #f3f4f6; border-radius: 4px; border: 1px solid #4b5563; cursor: pointer; }
+        .ak-add-btn { padding: 8px 16px; background: #2563eb; color: white; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; }
+        .ak-add-btn:hover { background: #1d4ed8; }
+      `}</style>
+    </div>
+  );
+}