新增对外客户访问体系:share token + end-user 账号

- 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
This commit is contained in:
朱潮 2026-06-26 14:10:43 +08:00
parent 0b1f3d411f
commit 680dd02595
6 changed files with 519 additions and 8 deletions

View File

@ -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)

View File

@ -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 表示已吊销)';

View File

@ -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
)

441
routes/bot_share.py Normal file
View File

@ -0,0 +1,441 @@
"""
对外 Bot 分享与客户账号管理接口
包含三组接口
1. share token CRUDbot 主人生成/列出/吊销对外访问凭证
2. end-user CRUDbot 主人添加/查看/重置/删除客户账号
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:
"""要求当前请求是 adminis_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 CRUDadmin 用) ==============
@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 CRUDadmin 用) ==============
@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,
)

View File

@ -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)

View File

@ -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):