新增对外客户访问体系: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:
parent
0b1f3d411f
commit
680dd02595
@ -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)
|
||||
|
||||
|
||||
42
migrations/add_end_user_and_bot_share_tokens.sql
Normal file
42
migrations/add_end_user_and_bot_share_tokens.sql
Normal 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 表示已吊销)';
|
||||
@ -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
441
routes/bot_share.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user