diff --git a/.gitignore b/.gitignore index b5739e49..f6a26b2d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,7 @@ cache *.local.py *.local.js *.local.yaml - +*.local/* *.pdf src/data diff --git a/backend/package/yuxi/storage/postgres/models_business.py b/backend/package/yuxi/storage/postgres/models_business.py index c63674ee..6d625167 100644 --- a/backend/package/yuxi/storage/postgres/models_business.py +++ b/backend/package/yuxi/storage/postgres/models_business.py @@ -75,6 +75,9 @@ class User(Base): # 关联部门 department = relationship("Department", back_populates="users") + # 关联 API Keys + api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan") + def to_dict(self, include_password: bool = False) -> dict[str, Any]: result = { "id": self.id, @@ -592,6 +595,53 @@ class SubAgent(Base): return spec +class APIKey(Base): + """API Key 模型""" + + __tablename__ = "api_keys" + + id = Column(Integer, primary_key=True, autoincrement=True) + key_hash = Column(String(64), nullable=False, unique=True, index=True) + key_prefix = Column(String(16), nullable=False) + name = Column(String(100), nullable=False) + + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + department_id = Column(Integer, ForeignKey("departments.id"), nullable=True, index=True) + + expires_at = Column(DateTime, nullable=True) + is_enabled = Column(Boolean, nullable=False, default=True) + last_used_at = Column(DateTime, nullable=True) + + created_by = Column(String(64), nullable=False) + created_at = Column(DateTime, default=utc_now_naive) + + # 关联 + user = relationship("User", back_populates="api_keys") + department = relationship("Department") + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "key_prefix": self.key_prefix, + "name": self.name, + "user_id": self.user_id, + "department_id": self.department_id, + "expires_at": format_utc_datetime(self.expires_at), + "is_enabled": bool(self.is_enabled), + "last_used_at": format_utc_datetime(self.last_used_at), + "created_by": self.created_by, + "created_at": format_utc_datetime(self.created_at), + } + + def is_valid(self) -> bool: + """检查 Key 是否有效""" + if not self.is_enabled: + return False + if self.expires_at and utc_now_naive() > self.expires_at: + return False + return True + + class AgentRun(Base): """AgentRun table - 运行任务表""" diff --git a/backend/server/routers/__init__.py b/backend/server/routers/__init__.py index 7399cf4d..83506672 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -12,6 +12,7 @@ from server.routers.subagent_router import subagents_router from server.routers.system_router import system from server.routers.task_router import tasks from server.routers.tool_router import tools +from server.routers.apikey_router import apikey_router _LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1") @@ -28,6 +29,7 @@ router.include_router(mcp) # /api/system/mcp-servers/* router.include_router(skills) # /api/system/skills/* router.include_router(subagents_router) # /api/system/subagents/* router.include_router(tools) # /api/system/tools/* +router.include_router(apikey_router) # /api/apikey/* if not _LITE_MODE: from server.routers.graph_router import graph diff --git a/backend/server/routers/apikey_router.py b/backend/server/routers/apikey_router.py new file mode 100644 index 00000000..f0206980 --- /dev/null +++ b/backend/server/routers/apikey_router.py @@ -0,0 +1,223 @@ +"""API Key 管理路由""" + +import hashlib +import secrets +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from yuxi.storage.postgres.models_business import User, APIKey +from server.utils.auth_middleware import get_db, get_required_user, get_superadmin_user +from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, utc_now_naive + +apikey_router = APIRouter(prefix="/apikey", tags=["apikey"]) + + +def generate_api_key() -> tuple[str, str, str]: + """生成新的 API Key + + Returns: (full_key, key_hash, key_prefix) + - full_key: 完整密钥,仅在创建时返回一次 + - key_hash: 存储到数据库的哈希值 + - key_prefix: 保存前缀用于显示 + """ + random_part = secrets.token_hex(24) + full_key = f"yxkey_{random_part}" + key_hash = hashlib.sha256(full_key.encode()).hexdigest() + key_prefix = full_key[:12] + return full_key, key_hash, key_prefix + + +class APIKeyCreate(BaseModel): + name: str + user_id: int | None = None + department_id: int | None = None + expires_at: str | None = None + + +class APIKeyUpdate(BaseModel): + name: str | None = None + expires_at: str | None = None + is_enabled: bool | None = None + + +class APIKeyResponse(BaseModel): + id: int + key_prefix: str + name: str + user_id: int | None + department_id: int | None + expires_at: str | None + is_enabled: bool + last_used_at: str | None + created_by: str + created_at: str + + +class APIKeyCreateResponse(BaseModel): + api_key: APIKeyResponse + secret: str + + +@apikey_router.get("/", response_model=dict) +async def list_api_keys( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + """列出所有 API Keys""" + result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()).offset(skip).limit(limit)) + api_keys = result.scalars().all() + + total_result = await db.execute(select(func.count(APIKey.id))) + total = total_result.scalar() + + return { + "api_keys": [key.to_dict() for key in api_keys], + "total": total, + } + + +@apikey_router.post("/", response_model=APIKeyCreateResponse) +async def create_api_key( + data: APIKeyCreate, + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), +): + """创建新 API Key(secret 仅在此处返回一次)""" + # 生成 Key + full_key, key_hash, key_prefix = generate_api_key() + + # 验证关联用户 + if data.user_id: + result = await db.execute(select(User).filter(User.id == data.user_id)) + user = result.scalar_one_or_none() + if not user or user.is_deleted: + raise HTTPException(status_code=404, detail="关联的用户不存在") + else: + # 自动绑定为当前登录用户 + data.user_id = current_user.id + + # 解析过期时间(转换为 naive datetime 以匹配数据库字段) + expires_at = None + if data.expires_at: + aware_dt = coerce_any_to_utc_datetime(data.expires_at) + if aware_dt: + expires_at = aware_dt.replace(tzinfo=None) + + # 创建记录 + api_key = APIKey( + key_hash=key_hash, + key_prefix=key_prefix, + name=data.name, + user_id=data.user_id, + department_id=data.department_id, + expires_at=expires_at, + created_by=str(current_user.id), + ) + + db.add(api_key) + await db.commit() + await db.refresh(api_key) + + return APIKeyCreateResponse( + api_key=APIKeyResponse(**api_key.to_dict()), + secret=full_key, + ) + + +@apikey_router.get("/{api_key_id}", response_model=dict) +async def get_api_key( + api_key_id: int, + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + """获取单个 API Key""" + result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id)) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=404, detail="API Key 不存在") + + return {"api_key": api_key.to_dict()} + + +@apikey_router.put("/{api_key_id}", response_model=dict) +async def update_api_key( + api_key_id: int, + data: APIKeyUpdate, + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + """更新 API Key""" + result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id)) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=404, detail="API Key 不存在") + + if data.name is not None: + api_key.name = data.name + + if data.expires_at is not None: + aware_dt = coerce_any_to_utc_datetime(data.expires_at) + api_key.expires_at = aware_dt.replace(tzinfo=None) if aware_dt else None + + if data.is_enabled is not None: + api_key.is_enabled = data.is_enabled + + await db.commit() + await db.refresh(api_key) + + return {"api_key": api_key.to_dict()} + + +@apikey_router.delete("/{api_key_id}", response_model=dict) +async def delete_api_key( + api_key_id: int, + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + """删除 API Key""" + result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id)) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=404, detail="API Key 不存在") + + await db.delete(api_key) + await db.commit() + + return {"success": True} + + +@apikey_router.post("/{api_key_id}/regenerate", response_model=APIKeyCreateResponse) +async def regenerate_api_key( + api_key_id: int, + current_user: User = Depends(get_superadmin_user), + db: AsyncSession = Depends(get_db), +): + """重新生成 API Key 密钥(secret 仅在此处返回一次)""" + result = await db.execute(select(APIKey).filter(APIKey.id == api_key_id)) + api_key = result.scalar_one_or_none() + + if not api_key: + raise HTTPException(status_code=404, detail="API Key 不存在") + + # 生成新密钥 + full_key, key_hash, key_prefix = generate_api_key() + + api_key.key_hash = key_hash + api_key.key_prefix = key_prefix + + await db.commit() + await db.refresh(api_key) + + return APIKeyCreateResponse( + api_key=APIKeyResponse(**api_key.to_dict()), + secret=full_key, + ) diff --git a/backend/server/utils/auth_middleware.py b/backend/server/utils/auth_middleware.py index a4ec7998..9dc6fda5 100644 --- a/backend/server/utils/auth_middleware.py +++ b/backend/server/utils/auth_middleware.py @@ -1,13 +1,16 @@ +import hashlib import re -from fastapi import Depends, HTTPException, status +from fastapi import Depends, Header, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from yuxi.storage.postgres.manager import pg_manager -from yuxi.storage.postgres.models_business import User +from yuxi.storage.postgres.models_business import User, APIKey from server.utils.auth_utils import AuthUtils +from yuxi.utils.datetime_utils import utc_now_naive # 定义OAuth2密码承载器,指定token URL oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False) @@ -29,20 +32,76 @@ async def get_db(): yield db +async def _verify_api_key(key: str, db: AsyncSession) -> tuple[User | None, APIKey | None]: + """验证 API Key 并返回关联用户和 APIKey 对象""" + key_hash = hashlib.sha256(key.encode()).hexdigest() + + result = await db.execute(select(APIKey).filter(APIKey.key_hash == key_hash)) + api_key = result.scalar_one_or_none() + + if api_key is None: + return None, None + + if not api_key.is_enabled: + return None, None + + if api_key.expires_at and utc_now_naive() > api_key.expires_at: + return None, None + + if api_key.user_id: + result = await db.execute(select(User).filter(User.id == api_key.user_id)) + user = result.scalar_one_or_none() + if user and not user.is_deleted: + return user, api_key + + if api_key.department_id: + result = await db.execute( + select(User).filter(User.department_id == api_key.department_id, User.role.in_(["admin", "superadmin"])) + ) + user = result.scalar_one_or_none() + if user and not user.is_deleted: + return user, api_key + + result = await db.execute(select(User).filter(User.role == "superadmin", User.is_deleted == 0).limit(1)) + user = result.scalar_one_or_none() + if user: + return user, api_key + + return None, None + + # 获取当前用户(异步版本) -async def get_current_user(token: str | None = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)): +async def get_current_user( + authorization: str | None = Header(None), + db: AsyncSession = Depends(get_db), +): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的凭证", headers={"WWW-Authenticate": "Bearer"}, ) - # 允许无token访问公开路径 - if token is None: + if authorization is None: return None + if not authorization.startswith("Bearer "): + return None + + token = authorization.split("Bearer ")[1] + if not token: + return None + + # 根据 token 前缀判断认证方式 + if token.startswith("yxkey_"): + # API Key 认证 + user, api_key_obj = await _verify_api_key(token, db) + if user is not None and api_key_obj is not None: + api_key_obj.last_used_at = utc_now_naive() + await db.commit() + return user + + # JWT Token 认证 try: - # 验证token payload = AuthUtils.verify_access_token(token) user_id = payload.get("sub") if user_id is None: @@ -50,17 +109,12 @@ async def get_current_user(token: str | None = Depends(oauth2_scheme), db: Async except JWTError: raise credentials_exception except ValueError as e: - # 捕获AuthUtils.verify_access_token可能抛出的ValueError - # 例如令牌过期或无效 raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=str(e), # 将错误信息直接传递给客户端 + detail=str(e), headers={"WWW-Authenticate": "Bearer"}, ) - # 查找用户(异步版本) - from sqlalchemy import select - result = await db.execute(select(User).filter(User.id == int(user_id))) user = result.scalar_one_or_none() if user is None: diff --git a/backend/test/api/test_apikey_router.py b/backend/test/api/test_apikey_router.py new file mode 100644 index 00000000..41964a42 --- /dev/null +++ b/backend/test/api/test_apikey_router.py @@ -0,0 +1,234 @@ +""" +Integration tests for API Key router endpoints. +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_list_api_keys_requires_auth(test_client): + """List API keys should require authentication.""" + response = await test_client.get("/api/apikey/") + assert response.status_code == 401 + + +async def test_list_api_keys_requires_admin(test_client, admin_headers): + """List API keys should require admin privileges.""" + response = await test_client.get("/api/apikey/", headers=admin_headers) + assert response.status_code == 200, response.text + data = response.json() + assert "api_keys" in data + assert "total" in data + + +async def test_create_api_key(test_client, admin_headers): + """Admin should be able to create a new API key.""" + payload = { + "name": "Test API Key", + } + response = await test_client.post("/api/apikey/", json=payload, headers=admin_headers) + assert response.status_code == 200, response.text + data = response.json() + assert "api_key" in data + assert "secret" in data + assert data["api_key"]["name"] == "Test API Key" + assert data["api_key"]["key_prefix"].startswith("yxkey_") + # Note: The "****" suffix is added by the frontend, not stored in backend + assert data["api_key"]["key_prefix"] == "yxkey_144cba" or data["api_key"]["key_prefix"].startswith("yxkey_") + # Secret should start with the prefix + assert data["secret"].startswith(data["api_key"]["key_prefix"][:6]) + return data + + +async def test_get_api_key(test_client, admin_headers): + """Admin should be able to get a single API key.""" + # First create a key + create_response = await test_client.post( + "/api/apikey/", json={"name": "Get Test"}, headers=admin_headers + ) + assert create_response.status_code == 200 + created = create_response.json()["api_key"] + + # Then retrieve it + response = await test_client.get(f"/api/apikey/{created['id']}", headers=admin_headers) + assert response.status_code == 200, response.text + data = response.json() + assert data["api_key"]["id"] == created["id"] + assert data["api_key"]["name"] == "Get Test" + + +async def test_update_api_key(test_client, admin_headers): + """Admin should be able to update an API key.""" + # Create a key + create_response = await test_client.post( + "/api/apikey/", json={"name": "Update Test"}, headers=admin_headers + ) + assert create_response.status_code == 200 + created = create_response.json()["api_key"] + + # Update it + response = await test_client.put( + f"/api/apikey/{created['id']}", + json={"name": "Updated Name", "is_enabled": False}, + headers=admin_headers, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["api_key"]["name"] == "Updated Name" + assert data["api_key"]["is_enabled"] is False + + +async def test_delete_api_key(test_client, admin_headers): + """Admin should be able to delete an API key.""" + # Create a key + create_response = await test_client.post( + "/api/apikey/", json={"name": "Delete Test"}, headers=admin_headers + ) + assert create_response.status_code == 200 + created = create_response.json()["api_key"] + + # Delete it + response = await test_client.delete(f"/api/apikey/{created['id']}", headers=admin_headers) + assert response.status_code == 200, response.text + assert response.json()["success"] is True + + # Verify it's gone + get_response = await test_client.get(f"/api/apikey/{created['id']}", headers=admin_headers) + assert get_response.status_code == 404 + + +async def test_regenerate_api_key(test_client, admin_headers): + """Admin should be able to regenerate an API key.""" + # Create a key + create_response = await test_client.post( + "/api/apikey/", json={"name": "Regenerate Test"}, headers=admin_headers + ) + assert create_response.status_code == 200 + original_secret = create_response.json()["secret"] + created = create_response.json()["api_key"] + + # Regenerate it + response = await test_client.post(f"/api/apikey/{created['id']}/regenerate", headers=admin_headers) + assert response.status_code == 200, response.text + data = response.json() + assert "secret" in data + assert data["secret"] != original_secret + assert data["api_key"]["key_prefix"] != original_secret[:12] + + +async def test_api_key_auth_chat_endpoint(test_client, admin_headers): + """Test that API Key can be used to authenticate to chat endpoint via Bearer token.""" + # Create an API key + create_response = await test_client.post( + "/api/apikey/", json={"name": "Chat Auth Test"}, headers=admin_headers + ) + assert create_response.status_code == 200 + api_key_secret = create_response.json()["secret"] + created = create_response.json()["api_key"] + + try: + # Get default agent + agent_response = await test_client.get("/api/chat/default_agent", headers=admin_headers) + assert agent_response.status_code == 200 + agent_id = agent_response.json().get("default_agent_id") + if not agent_id: + pytest.skip("No default agent configured") + + # Call chat endpoint with API Key using Bearer format (streaming response) + async with test_client.stream( + "POST", + f"/api/chat/agent/{agent_id}", + json={"query": "Hello"}, + headers={"Authorization": f"Bearer {api_key_secret}"}, + ) as response: + assert response.status_code == 200, response.text + assert response.headers.get("content-type") == "application/json" + finally: + # Cleanup: delete the test API key + await test_client.delete(f"/api/apikey/{created['id']}", headers=admin_headers) + + +async def test_api_key_auth_requires_valid_key(test_client): + """Test that invalid API Key is rejected.""" + # Call chat endpoint with invalid API Key + response = await test_client.post( + "/api/chat/agent/ChatbotAgent", + json={"query": "Hello"}, + headers={"Authorization": "Bearer yxkey_invalid_key_that_does_not_exist"}, + ) + assert response.status_code == 401, response.text + + +async def test_api_key_auth_requires_bearer_prefix(test_client, admin_headers): + """Test that API Key must be prefixed with 'Bearer '.""" + # Create an API key + admin_response = await test_client.post( + "/api/apikey/", json={"name": "Prefix Test"}, headers=admin_headers + ) + assert admin_response.status_code == 200 + api_key_secret = admin_response.json()["secret"] + created = admin_response.json()["api_key"] + + try: + # Call without Bearer prefix should fail + response = await test_client.post( + "/api/chat/agent/ChatbotAgent", + json={"query": "Hello"}, + headers={"Authorization": api_key_secret}, # Missing "Bearer " prefix + ) + assert response.status_code == 401, response.text + finally: + # Cleanup: delete the test API key + await test_client.delete(f"/api/apikey/{created['id']}", headers=admin_headers) + + +async def test_jwt_still_works_after_apikey_auth(test_client, admin_headers): + """Test that JWT Bearer tokens still work after API Key changes.""" + # Get default agent + agent_response = await test_client.get("/api/chat/default_agent", headers=admin_headers) + assert agent_response.status_code == 200 + agent_id = agent_response.json().get("default_agent_id") + if not agent_id: + pytest.skip("No default agent configured") + + # Call chat with JWT Bearer token (admin_headers) - streaming response + async with test_client.stream( + "POST", + f"/api/chat/agent/{agent_id}", + json={"query": "Hello"}, + headers=admin_headers, + ) as response: + assert response.status_code == 200, response.text + assert response.headers.get("content-type") == "application/json" + + +async def test_api_key_auto_binds_to_current_user(test_client, admin_headers): + """Test that API Key created without user_id is auto-bound to creator.""" + # Create API key as admin + create_response = await test_client.post( + "/api/apikey/", json={"name": "Auto Bind Test"}, headers=admin_headers + ) + assert create_response.status_code == 200 + created = create_response.json()["api_key"] + + try: + # Verify user_id is set (auto-bound to admin) + assert created["user_id"] is not None, "API Key should be auto-bound to creator" + + # Verify the key can be used for auth + api_key_secret = create_response.json()["secret"] + async with test_client.stream( + "POST", + "/api/chat/agent/ChatbotAgent", + json={"query": "Hello"}, + headers={"Authorization": f"Bearer {api_key_secret}"}, + ) as response: + assert response.status_code == 200, response.text + finally: + # Cleanup: delete the test API key + await test_client.delete(f"/api/apikey/{created['id']}", headers=admin_headers) + + diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 3001ef10..930d2feb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -54,7 +54,8 @@ export default defineConfig({ { text: '文档解析', link: '/advanced/document-processing' }, { text: '品牌自定义', link: '/advanced/branding' }, { text: '其他配置', link: '/advanced/misc' }, - { text: '生产部署', link: '/advanced/deployment' } + { text: '生产部署', link: '/advanced/deployment' }, + { text: 'API Key 外部集成', link: '/advanced/api-key-integration' } ] }, { diff --git a/docs/advanced/api-key-integration.md b/docs/advanced/api-key-integration.md new file mode 100644 index 00000000..0611533d --- /dev/null +++ b/docs/advanced/api-key-integration.md @@ -0,0 +1,81 @@ +# API Key 外部集成 + +Yuxi 平台提供了 API Key 认证机制,允许外部系统在无需用户登录的情况下调用智能体对话接口。本文档详细介绍 API Key 的使用方法、接口调用方式以及安全注意事项。 + +## API Key 概述 + +API Key 是一种用于身份验证的密钥字符串,外部系统可以通过它在请求头中携带凭据来访问 Yuxi 的对话接口。与传统的用户名密码登录方式相比,API Key 更加适合用于系统间的自动化调用场景。Yuxi 的 API Key 以 `yxkey_` 为前缀,长度为 56 个字符,采用 SHA-256 哈希存储,确保密钥本身不会在数据库中明文保存。系统会记录每个 API Key 的最后使用时间,方便管理员追踪使用情况。 + +## 创建 API Key + +登录系统后,进入 API Key 管理界面,可以创建新的密钥。创建时需要为 API Key 设置一个名称,用于标识其用途,例如"外部客服系统"或"数据同步服务"。创建的 API Key 会自动绑定到当前登录用户,绑定后的 API Key 在调用接口时会以该用户的身份执行操作。API Key 还支持设置过期时间,过期后该密钥将自动失效。 + +需要特别注意的是,创建 API Key 时返回的完整密钥(secret)只会显示一次,务必在创建时将其安全保存。如果遗失,需要通过"重新生成"功能生成新的密钥,原有的密钥将立即失效。 + +## 接口调用方式 + +外部系统通过 HTTP 请求调用 Yuxi 的对话接口,需要在请求头中携带 API Key。接口地址为 `POST /api/chat/agent/{agent_id}`,其中 `{agent_id}` 是目标智能体的标识符。请求头需要包含 `Authorization` 字段,值格式为 `Bearer `,其中 `` 是创建 API Key 时获取的完整密钥。请求体为 JSON 格式,包含 `query` 字段表示用户问题,以及可选的 `config` 和 `meta` 字段用于配置对话参数。 + +以下是一个典型的 Python 调用示例: + +```python +import requests +import json + +url = "http://your-yuxi-server/api/chat/agent/agent_id" +headers = { + "Authorization": "Bearer yxkey_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "Content-Type": "application/json" +} +payload = { + "query": "你好,请介绍一下你自己", + "config": {}, + "meta": {} +} + +response = requests.post(url, headers=headers, json=payload, stream=True) +for line in response.iter_lines(): + if line: + print(line.decode('utf-8')) +``` + +该接口返回的是流式响应(Server-Sent Events),每个事件是一行 JSON 数据,包含对话的增量内容。客户端需要逐行解析并处理这些事件来构建完整的对话结果。 + +## 响应格式 + +接口返回的流式响应采用 JSON Lines 格式,每行代表一个事件。常见的事件类型包括: + +`event: data` 表示数据事件,携带实际的对话内容。`event: error` 表示错误事件,当对话过程中发生错误时会收到此类事件。`event: done` 表示完成事件,标志对话结束。 + +每次调用都会在响应中包含 `request_id`,这是本次对话的唯一标识符,可用于日志追踪和问题排查。如果需要在多轮对话中使用同一个会话,可以通过 `config.thread_id` 参数指定线程 ID,系统会将同一线程的消息串联起来形成连贯的对话上下文。 + +## 认证方式 + +Yuxi 的 API 接口统一支持两种认证方式: + +1. **API Key 认证**:使用 `Authorization: Bearer ` 格式,其中 API Key 必须以 `yxkey_` 前缀开头 +2. **JWT Token 认证**:使用 `Authorization: Bearer ` 格式 + +系统根据 token 的前缀自动判断认证方式。以 `yxkey_` 开头的 token 被视为 API Key,其他 token 则作为 JWT Token 处理。这种设计使得同一个接口可以同时支持外部系统(使用 API Key)和内部前端应用(使用用户登录态)调用。 + +## 安全注意事项 + +保管好 API Key 密钥是最重要的安全原则。由于 API Key 一旦泄露就可能被滥用,建议不要将密钥硬编码在代码中,而是通过环境变量或配置中心来管理。如果怀疑密钥泄露,应立即在管理界面禁用该 API Key 并重新生成。启用密钥过期功能是一种良好的安全实践,可以设置较短的有效期并定期轮换。 + +在生产环境中,建议为不同的外部系统创建独立的 API Key,这样可以在某个密钥泄露时快速定位问题并限制影响范围。同时,建议在管理界面定期查看 API Key 的使用记录,检查是否存在异常调用情况。 + +关于权限控制,API Key 的权限等同于其绑定的用户在系统中的角色。如果 API Key 绑定到特定用户,则该用户的所有权限都会体现在 API Key 的操作中,因此务必妥善保管。 + +## 常见问题 + +**Q: API Key 认证失败返回什么错误?** +A: 认证失败时返回 401 Unauthorized 错误,错误信息为"无效的凭证"。请检查请求头中 `Authorization` 字段的格式是否正确,是否包含完整的密钥,且密钥必须以 `yxkey_` 开头。 + +**Q: 可以同时使用 API Key 和 JWT Token 吗?** +A: 不可以。系统根据 token 前缀自动判断认证方式。以 `yxkey_` 开头的 token 使用 API Key 认证,其他 token 使用 JWT 认证。 + +**Q: API Key 是否有调用频率限制?** +A: 目前没有单独的频率限制,但 API Key 的行为等同于其绑定的用户身份,因此会受到用户角色相关的一些限制。 + +**Q: 对话返回的内容是乱码怎么办?** +A: 确保客户端正确处理了 UTF-8 编码。流式响应中可能包含中文字符,需要使用正确的编码方式解析。如果在终端显示乱码,可以检查终端的编码设置。 diff --git a/docs/changelog/roadmap.md b/docs/changelog/roadmap.md index 732a9057..f1728a3f 100644 --- a/docs/changelog/roadmap.md +++ b/docs/changelog/roadmap.md @@ -6,7 +6,6 @@ ### 看板 - 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息 -- 系统层面添加 apikey,在智能体、知识库调用中支持 apikey 以支持外部调用 - 部分场景应该使用默认模型作为默认值而不是空值 - 检索测试中,添加问答 - 集成 Memory,基于 deepagents 的文件后端实现 @@ -26,6 +25,7 @@ +- 新增 API Key 管理功能,支持外部系统通过 API Key 调用 Agent 对话接口(`POST /api/chat/agent/{agent_id}`)。统一使用 `Authorization: Bearer ` 认证,API Key 以 `yxkey_` 开头。获取 API Key 即代表拥有绑定用户的所有接口访问权限。 - 将 后端代码 和 agents 解耦,agents 作为单独的 package 使用 - 添加 subagents diff --git a/web/src/apis/apikey_api.js b/web/src/apis/apikey_api.js new file mode 100644 index 00000000..445dc78e --- /dev/null +++ b/web/src/apis/apikey_api.js @@ -0,0 +1,15 @@ +import { apiSuperAdminGet, apiSuperAdminPost, apiSuperAdminPut, apiDelete } from './base' + +export const apikeyApi = { + list: (skip = 0, limit = 100) => apiSuperAdminGet('/api/apikey/', { params: { skip, limit } }), + + create: (data) => apiSuperAdminPost('/api/apikey/', data), + + get: (id) => apiSuperAdminGet(`/api/apikey/${id}`), + + update: (id, data) => apiSuperAdminPut(`/api/apikey/${id}`, data), + + delete: (id) => apiDelete(`/api/apikey/${id}`), + + regenerate: (id) => apiSuperAdminPost(`/api/apikey/${id}/regenerate`) +} diff --git a/web/src/components/ApiKeyManagementComponent.vue b/web/src/components/ApiKeyManagementComponent.vue new file mode 100644 index 00000000..fc5743d8 --- /dev/null +++ b/web/src/components/ApiKeyManagementComponent.vue @@ -0,0 +1,464 @@ + + + + + diff --git a/web/src/components/SettingsModal.vue b/web/src/components/SettingsModal.vue index b5a0e660..bbaf3739 100644 --- a/web/src/components/SettingsModal.vue +++ b/web/src/components/SettingsModal.vue @@ -49,6 +49,15 @@ 部门管理 +
+ + API Key +
@@ -85,6 +94,14 @@ > 部门管理 + @@ -105,6 +122,10 @@
+ +
+ +
@@ -115,10 +136,12 @@ import { ref, computed, watch } from 'vue' import { useUserStore } from '@/stores/user' import { SettingOutlined, CodeOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons-vue' +import { Key as KeyIcon } from 'lucide-vue-next' import BasicSettingsSection from '@/components/BasicSettingsSection.vue' import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue' import UserManagementComponent from '@/components/UserManagementComponent.vue' import DepartmentManagementComponent from '@/components/DepartmentManagementComponent.vue' +import ApiKeyManagementComponent from '@/components/ApiKeyManagementComponent.vue' const props = defineProps({ visible: {