From 680dd02595a72ad82ac77178b32fa01e5ca1a77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E6=BD=AE?= Date: Fri, 26 Jun 2026 14:10:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=B9=E5=A4=96=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E8=AE=BF=E9=97=AE=E4=BD=93=E7=B3=BB=EF=BC=9Ashare=20t?= =?UTF-8?q?oken=20+=20end-user=20=E8=B4=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrations: 加 agent_user.is_end_user 列、新建 agent_bot_share_tokens 表 - routes/bot_share: share token CRUD、客户账号 CRUD、公开 bot-share 信息接口 - routes/bot_manager: 登录/校验响应带 is_end_user,客户账号跳过 New API 同步 - routes/chat: /api/v3/chat/completions 增加 share_token 与 bot_id 一致性校验 - utils/api_models: ChatRequestV3 加 share_token 字段 - fastapi_app: 注册 bot_share router --- fastapi_app.py | 3 +- .../add_end_user_and_bot_share_tokens.sql | 42 ++ routes/bot_manager.py | 33 +- routes/bot_share.py | 441 ++++++++++++++++++ routes/chat.py | 6 + utils/api_models.py | 2 + 6 files changed, 519 insertions(+), 8 deletions(-) create mode 100644 migrations/add_end_user_and_bot_share_tokens.sql create mode 100644 routes/bot_share.py diff --git a/fastapi_app.py b/fastapi_app.py index 728af3c..bcbf39e 100644 --- a/fastapi_app.py +++ b/fastapi_app.py @@ -81,7 +81,7 @@ from utils.log_util.logger import init_with_fastapi logger = logging.getLogger('app') -from routes import chat, files, projects, system, skill_manager, database, memory, bot_manager, knowledge_base, payment, voice +from routes import chat, files, projects, system, skill_manager, database, memory, bot_manager, knowledge_base, payment, voice, bot_share from routes.webdav import wsgidav_app @@ -253,6 +253,7 @@ app.include_router(system.router) app.include_router(skill_manager.router) app.include_router(database.router) app.include_router(bot_manager.router) +app.include_router(bot_share.router) app.include_router(payment.router) app.include_router(memory.router) diff --git a/migrations/add_end_user_and_bot_share_tokens.sql b/migrations/add_end_user_and_bot_share_tokens.sql new file mode 100644 index 0000000..ef8d921 --- /dev/null +++ b/migrations/add_end_user_and_bot_share_tokens.sql @@ -0,0 +1,42 @@ +-- ============================================================ +-- End-user & Bot Share Token Migration +-- 1. agent_user 增加 is_end_user 列(区分对外客户账号) +-- 2. 新增 agent_bot_share_tokens 表(一次性可吊销的 bot 对外访问凭证) +-- 注意:现有 bot_shares 表是 admin 之间的协作分享(viewer/editor), +-- 与本次"对外客户访问"语义无关,故新表另起名字避免冲突。 +-- ============================================================ + +-- 1. agent_user 增加 is_end_user +ALTER TABLE agent_user +ADD COLUMN IF NOT EXISTS is_end_user BOOLEAN DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_agent_user_is_end_user +ON agent_user(is_end_user) WHERE is_end_user = TRUE; + +COMMENT ON COLUMN agent_user.is_end_user IS '是否为对外客户账号(不可登录 admin 后台,仅能通过 share token 访问指定 bot)'; + +-- 2. agent_bot_share_tokens 表 +CREATE TABLE IF NOT EXISTS agent_bot_share_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bot_id UUID NOT NULL REFERENCES agent_bots(id) ON DELETE CASCADE, + share_token VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(255), + created_by UUID NOT NULL REFERENCES agent_user(id) ON DELETE CASCADE, + expires_at TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_agent_bot_share_tokens_token +ON agent_bot_share_tokens(share_token); + +CREATE INDEX IF NOT EXISTS idx_agent_bot_share_tokens_bot_id +ON agent_bot_share_tokens(bot_id); + +CREATE INDEX IF NOT EXISTS idx_agent_bot_share_tokens_created_by +ON agent_bot_share_tokens(created_by); + +COMMENT ON TABLE agent_bot_share_tokens IS 'bot 对外访问凭证:bot 主人生成 share_token,客户带 token 登录后访问对应 bot'; +COMMENT ON COLUMN agent_bot_share_tokens.share_token IS '随机字符串,作为 URL path 的一部分'; +COMMENT ON COLUMN agent_bot_share_tokens.expires_at IS '过期时间(NULL 表示永不过期,除非被吊销)'; +COMMENT ON COLUMN agent_bot_share_tokens.revoked_at IS '吊销时间(非 NULL 表示已吊销)'; diff --git a/routes/bot_manager.py b/routes/bot_manager.py index 61b9ea8..7b828db 100644 --- a/routes/bot_manager.py +++ b/routes/bot_manager.py @@ -93,6 +93,13 @@ ADMIN_PASSWORD = "Admin123" # 生产环境应使用环境变量 TOKEN_EXPIRE_HOURS = 24 +# ============== 内部哨兵异常 ============== + +class _SkipNewApiSync(Exception): + """用于在 try 块内提前跳过 New API 同步(仅供本模块内部使用)""" + pass + + # ============== 认证函数 ============== def verify_auth(authorization: Optional[str]) -> None: @@ -446,6 +453,7 @@ class UserLoginResponse(BaseModel): email: Optional[str] = None is_admin: bool = False is_subaccount: bool = False # 是否是子账号 + is_end_user: bool = False # 是否为对外客户账号 parent_id: Optional[str] = None # 主账号ID expires_at: str single_agent: Optional[dict] = None # 单智能体模式配置 @@ -458,6 +466,7 @@ class UserVerifyResponse(BaseModel): username: Optional[str] = None is_admin: bool = False is_subaccount: bool = False # 是否是子账号 + is_end_user: bool = False # 是否为对外客户账号 class UserInfoResponse(BaseModel): @@ -2664,7 +2673,7 @@ async def user_login(request: UserLoginRequest): # 1. 验证用户名和密码 password_hash = hash_password(request.password) await cursor.execute(""" - SELECT id, username, email, is_active, is_admin, is_subaccount, parent_id + SELECT id, username, email, is_active, is_admin, is_subaccount, parent_id, is_end_user FROM agent_user WHERE username = %s AND password_hash = %s """, (request.username, password_hash)) @@ -2676,7 +2685,7 @@ async def user_login(request: UserLoginRequest): detail="用户名或密码错误" ) - user_id, username, email, is_active, is_admin, is_subaccount, parent_id = row + user_id, username, email, is_active, is_admin, is_subaccount, parent_id, is_end_user = row if not is_active: raise HTTPException( @@ -2707,10 +2716,13 @@ async def user_login(request: UserLoginRequest): """, (user_id, token, expires_at)) # 5. 尝试同步 New API session 和令牌(静默失败) + # 对外客户账号不参与 New API 体系:客户调用大模型时走 bot 主人的 token new_api_session = None new_api_user_id = None new_api_token = None try: + if is_end_user: + raise _SkipNewApiSync() proxy = get_new_api_proxy() # 使用 username 登录 New API logger.info(f"Attempting New API login for user {username}") @@ -2765,14 +2777,17 @@ async def user_login(request: UserLoginRequest): logger.info(f"New API session, user_id and token stored for user {username} after registration") else: logger.warning(f"New API register also failed for user {username}: {register_result.get('message')}") + except _SkipNewApiSync: + # 客户账号无需同步 New API + pass except Exception as e: # 静默失败,不影响本地登录 logger.warning(f"New API login sync failed for user {username}: {e}") await conn.commit() - # 获取单智能体配置 - single_agent_config = await get_or_create_single_agent_bot(str(user_id), pool) + # 获取单智能体配置(客户账号不参与) + single_agent_config = None if is_end_user else await get_or_create_single_agent_bot(str(user_id), pool) return UserLoginResponse( token=token, @@ -2781,6 +2796,7 @@ async def user_login(request: UserLoginRequest): email=email, is_admin=is_admin or False, is_subaccount=is_subaccount or False, + is_end_user=is_end_user or False, parent_id=str(parent_id) if parent_id else None, expires_at=expires_at.isoformat(), single_agent=single_agent_config @@ -2802,24 +2818,27 @@ async def user_verify(authorization: Optional[str] = Header(None)): is_admin_flag = False is_subaccount_flag = False - if valid and user_id: + is_end_user_flag = False + if valid and user_id and user_id != "__masterkey__": pool = get_db_pool_manager().pool async with pool.connection() as conn: async with conn.cursor() as cursor: await cursor.execute(""" - SELECT is_admin, is_subaccount FROM agent_user WHERE id = %s + SELECT is_admin, is_subaccount, is_end_user FROM agent_user WHERE id = %s """, (user_id,)) row = await cursor.fetchone() if row: is_admin_flag = row[0] or False is_subaccount_flag = row[1] or False + is_end_user_flag = row[2] or False return UserVerifyResponse( valid=valid, user_id=user_id, username=username, is_admin=is_admin_flag, - is_subaccount=is_subaccount_flag + is_subaccount=is_subaccount_flag, + is_end_user=is_end_user_flag ) diff --git a/routes/bot_share.py b/routes/bot_share.py new file mode 100644 index 0000000..5265da8 --- /dev/null +++ b/routes/bot_share.py @@ -0,0 +1,441 @@ +""" +对外 Bot 分享与客户账号管理接口 + +包含三组接口: +1. share token CRUD:bot 主人生成/列出/吊销对外访问凭证 +2. end-user CRUD:bot 主人添加/查看/重置/删除客户账号 +3. public bot-share 信息:客户登录后通过 share_token 获取 bot 渲染所需的最小字段 + +设计要点: +- 客户账号物理上仍写入 agent_user 表,但 is_end_user=True,且不参与 New API 同步 +- share_token 是一次性可吊销的字符串,与 agent_bots.owner_id 关联 +- 大模型调用时仍按 bot owner 的 new_api_token 走,与现有 chat 流程一致 +""" + +import logging +import secrets +from datetime import datetime +from typing import Optional, List +from fastapi import APIRouter, HTTPException, Header +from pydantic import BaseModel + +from agent.db_pool_manager import get_db_pool_manager +from routes.bot_manager import ( + verify_user_auth, + is_bot_owner, + is_admin_user, + hash_password, + get_parent_user_id, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic 模型 ============== + +class CreateShareTokenRequest(BaseModel): + name: Optional[str] = None + expires_at: Optional[str] = None # ISO 8601 + + +class ShareTokenItem(BaseModel): + id: str + bot_id: str + share_token: str + name: Optional[str] = None + created_at: str + expires_at: Optional[str] = None + revoked_at: Optional[str] = None + + +class ShareTokenListResponse(BaseModel): + items: List[ShareTokenItem] + + +class CreateEndUserRequest(BaseModel): + username: str + password: str + email: Optional[str] = None + + +class EndUserItem(BaseModel): + id: str + username: str + email: Optional[str] = None + is_active: bool + created_at: str + last_login: Optional[str] = None + + +class EndUserListResponse(BaseModel): + items: List[EndUserItem] + + +class ResetEndUserPasswordRequest(BaseModel): + password: str + + +class PublicBotInfoResponse(BaseModel): + bot_id: str + bot_uuid: str + name: str + avatar_url: Optional[str] = None + welcome: Optional[str] = None + suggested_questions: Optional[list] = None + settings: Optional[dict] = None # 仅展示用最小字段 + + +class SimpleSuccessResponse(BaseModel): + success: bool = True + message: Optional[str] = None + + +# ============== 内部权限辅助 ============== + +async def _require_admin_user(authorization: Optional[str]) -> str: + """要求当前请求是 admin(is_admin=True 或 masterkey),返回 user_id""" + valid, user_id, _ = await verify_user_auth(authorization) + if not valid or not user_id: + raise HTTPException(status_code=401, detail="未登录或 token 无效") + if user_id == "__masterkey__": + return user_id + if not await is_admin_user(authorization): + raise HTTPException(status_code=403, detail="仅 admin 可执行此操作") + return user_id + + +async def _require_end_user(authorization: Optional[str]) -> str: + """要求当前请求是 end-user,返回 user_id""" + valid, user_id, _ = await verify_user_auth(authorization) + if not valid or not user_id or user_id == "__masterkey__": + raise HTTPException(status_code=401, detail="未登录或 token 无效") + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + "SELECT is_end_user FROM agent_user WHERE id = %s", + (user_id,) + ) + row = await cursor.fetchone() + if not row or not row[0]: + raise HTTPException(status_code=403, detail="仅客户账号可访问此接口") + return user_id + + +# ============== Share Token CRUD(admin 用) ============== + +@router.post("/api/v1/bots/{bot_id}/share-tokens", response_model=ShareTokenItem) +async def create_share_token( + bot_id: str, + body: CreateShareTokenRequest, + authorization: Optional[str] = Header(None), +): + user_id = await _require_admin_user(authorization) + if user_id != "__masterkey__" and not await is_bot_owner(bot_id, user_id): + raise HTTPException(status_code=403, detail="您不是该 bot 的所有者") + + share_token = secrets.token_urlsafe(24) + expires_at = None + if body.expires_at: + try: + expires_at = datetime.fromisoformat(body.expires_at.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(status_code=400, detail="expires_at 格式无效,应为 ISO 8601") + + # masterkey 用户没有真实 created_by,需要回退到 bot owner_id + created_by = user_id + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + if user_id == "__masterkey__": + await cursor.execute( + "SELECT owner_id FROM agent_bots WHERE id = %s", + (bot_id,) + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="bot 不存在") + created_by = str(row[0]) + + await cursor.execute( + """ + INSERT INTO agent_bot_share_tokens (bot_id, share_token, name, created_by, expires_at) + VALUES (%s, %s, %s, %s, %s) + RETURNING id, created_at + """, + (bot_id, share_token, body.name, created_by, expires_at), + ) + row = await cursor.fetchone() + await conn.commit() + return ShareTokenItem( + id=str(row[0]), + bot_id=bot_id, + share_token=share_token, + name=body.name, + created_at=row[1].isoformat(), + expires_at=expires_at.isoformat() if expires_at else None, + revoked_at=None, + ) + + +@router.get("/api/v1/bots/{bot_id}/share-tokens", response_model=ShareTokenListResponse) +async def list_share_tokens( + bot_id: str, + authorization: Optional[str] = Header(None), +): + user_id = await _require_admin_user(authorization) + if user_id != "__masterkey__" and not await is_bot_owner(bot_id, user_id): + raise HTTPException(status_code=403, detail="您不是该 bot 的所有者") + + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + """ + SELECT id, share_token, name, created_at, expires_at, revoked_at + FROM agent_bot_share_tokens + WHERE bot_id = %s + ORDER BY created_at DESC + """, + (bot_id,), + ) + rows = await cursor.fetchall() + items = [ + ShareTokenItem( + id=str(r[0]), + bot_id=bot_id, + share_token=r[1], + name=r[2], + created_at=r[3].isoformat(), + expires_at=r[4].isoformat() if r[4] else None, + revoked_at=r[5].isoformat() if r[5] else None, + ) + for r in rows + ] + return ShareTokenListResponse(items=items) + + +@router.delete("/api/v1/bot-share-tokens/{share_token_id}", response_model=SimpleSuccessResponse) +async def revoke_share_token( + share_token_id: str, + authorization: Optional[str] = Header(None), +): + user_id = await _require_admin_user(authorization) + + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + "SELECT bot_id FROM agent_bot_share_tokens WHERE id = %s", + (share_token_id,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="share token 不存在") + bot_id = str(row[0]) + if user_id != "__masterkey__" and not await is_bot_owner(bot_id, user_id): + raise HTTPException(status_code=403, detail="您不是该 bot 的所有者") + + await cursor.execute( + "UPDATE agent_bot_share_tokens SET revoked_at = NOW() WHERE id = %s", + (share_token_id,), + ) + await conn.commit() + return SimpleSuccessResponse(message="已吊销") + + +# ============== End-user CRUD(admin 用) ============== + +@router.post("/api/v1/end-users", response_model=EndUserItem) +async def create_end_user( + body: CreateEndUserRequest, + authorization: Optional[str] = Header(None), +): + await _require_admin_user(authorization) + if not body.username or not body.password: + raise HTTPException(status_code=400, detail="username 和 password 必填") + + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + "SELECT id FROM agent_user WHERE username = %s", + (body.username,), + ) + if await cursor.fetchone(): + raise HTTPException(status_code=409, detail="用户名已存在") + + await cursor.execute( + """ + INSERT INTO agent_user (username, email, password_hash, is_admin, is_subaccount, is_end_user) + VALUES (%s, %s, %s, FALSE, FALSE, TRUE) + RETURNING id, created_at, is_active + """, + (body.username, body.email, hash_password(body.password)), + ) + row = await cursor.fetchone() + await conn.commit() + return EndUserItem( + id=str(row[0]), + username=body.username, + email=body.email, + is_active=row[2], + created_at=row[1].isoformat(), + last_login=None, + ) + + +@router.get("/api/v1/end-users", response_model=EndUserListResponse) +async def list_end_users(authorization: Optional[str] = Header(None)): + await _require_admin_user(authorization) + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + """ + SELECT id, username, email, is_active, created_at, last_login + FROM agent_user + WHERE is_end_user = TRUE + ORDER BY created_at DESC + """ + ) + rows = await cursor.fetchall() + items = [ + EndUserItem( + id=str(r[0]), + username=r[1], + email=r[2], + is_active=r[3], + created_at=r[4].isoformat(), + last_login=r[5].isoformat() if r[5] else None, + ) + for r in rows + ] + return EndUserListResponse(items=items) + + +@router.post("/api/v1/end-users/{end_user_id}/reset-password", response_model=SimpleSuccessResponse) +async def reset_end_user_password( + end_user_id: str, + body: ResetEndUserPasswordRequest, + authorization: Optional[str] = Header(None), +): + await _require_admin_user(authorization) + if not body.password: + raise HTTPException(status_code=400, detail="password 必填") + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + "SELECT id FROM agent_user WHERE id = %s AND is_end_user = TRUE", + (end_user_id,), + ) + if not await cursor.fetchone(): + raise HTTPException(status_code=404, detail="客户账号不存在") + + await cursor.execute( + "UPDATE agent_user SET password_hash = %s WHERE id = %s", + (hash_password(body.password), end_user_id), + ) + # 清理已有 token,强制重新登录 + await cursor.execute( + "DELETE FROM agent_user_tokens WHERE user_id = %s", + (end_user_id,), + ) + await conn.commit() + return SimpleSuccessResponse(message="密码已重置,原 token 已失效") + + +@router.delete("/api/v1/end-users/{end_user_id}", response_model=SimpleSuccessResponse) +async def delete_end_user( + end_user_id: str, + authorization: Optional[str] = Header(None), +): + await _require_admin_user(authorization) + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + "DELETE FROM agent_user WHERE id = %s AND is_end_user = TRUE", + (end_user_id,), + ) + await conn.commit() + return SimpleSuccessResponse(message="已删除") + + +# ============== Public bot-share 信息(客户用) ============== + +async def _resolve_share_token(share_token: str) -> str: + """校验 share_token 有效,返回对应的 bot_id""" + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + """ + SELECT bot_id, expires_at, revoked_at + FROM agent_bot_share_tokens + WHERE share_token = %s + """, + (share_token,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="分享链接无效") + bot_id, expires_at, revoked_at = row + if revoked_at is not None: + raise HTTPException(status_code=403, detail="分享链接已被吊销") + if expires_at is not None: + from datetime import timezone + if expires_at < datetime.now(timezone.utc): + raise HTTPException(status_code=403, detail="分享链接已过期") + return str(bot_id) + + +async def validate_share_token_for_bot(share_token: str, bot_id: str) -> bool: + """供 chat.py 校验 share_token 与 bot_id 一致,返回是否有效(无效抛 HTTPException)""" + resolved = await _resolve_share_token(share_token) + if resolved != bot_id: + raise HTTPException(status_code=403, detail="分享链接与 bot 不匹配") + return True + + +@router.get("/api/v1/public/bot-share/{share_token}", response_model=PublicBotInfoResponse) +async def get_public_bot_by_share( + share_token: str, + authorization: Optional[str] = Header(None), +): + await _require_end_user(authorization) + bot_id = await _resolve_share_token(share_token) + + pool = get_db_pool_manager().pool + async with pool.connection() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + """ + SELECT id, name, settings + FROM agent_bots + WHERE id = %s + """, + (bot_id,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="bot 不存在") + bot_uuid, name, settings = row + settings = settings or {} + # 只返回展示安全的字段,过滤掉可能含 prompt 等敏感信息的 key + safe_settings = { + k: v + for k, v in settings.items() + if k in ("language", "enable_thinking", "enable_memori", "tool_response") + } + return PublicBotInfoResponse( + bot_id=str(bot_uuid), + bot_uuid=str(bot_uuid), + name=name, + avatar_url=settings.get("avatar_url"), + welcome=settings.get("welcome") or settings.get("welcome_message"), + suggested_questions=settings.get("suggested_questions") or settings.get("guide_questions"), + settings=safe_settings, + ) diff --git a/routes/chat.py b/routes/chat.py index 229fc52..61ed4fe 100644 --- a/routes/chat.py +++ b/routes/chat.py @@ -928,6 +928,12 @@ async def chat_completions_v3(request: ChatRequestV3, authorization: Optional[st if not bot_id: raise HTTPException(status_code=400, detail="bot_id is required") + # 对外客户访问:必须带 share_token,且校验通过才放行 + # (admin 调用不传 share_token,按原有兼容逻辑放行) + if request.share_token: + from routes.bot_share import validate_share_token_for_bot + await validate_share_token_for_bot(request.share_token, bot_id) + # 可选的鉴权验证(如果传递了 authorization header) if authorization: expected_token = generate_v2_auth_token(bot_id) diff --git a/utils/api_models.py b/utils/api_models.py index 70b88f6..196c91b 100644 --- a/utils/api_models.py +++ b/utils/api_models.py @@ -99,6 +99,8 @@ class ChatRequestV3(BaseModel): model_id: Optional[str] = None dataset_ids: Optional[List[str]] = None skills: Optional[List[str]] = None + # 对外客户访问场景:必须携带 share_token 才能调用,且 share_token 必须匹配 bot_id + share_token: Optional[str] = None class VisionMessage(BaseModel):