feat: brand 白名单下发(站点覆盖/租户头像/CORS) + widget/chat 客户端 key 租户化 + 会话站点标
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,12 @@ async def ws_chat(websocket: WebSocket):
|
||||
init_data = await websocket.receive_text()
|
||||
init_msg = json.loads(init_data)
|
||||
|
||||
# Task 10: 客户端自报 site(空域名站点如 es 靠嵌入代码 data-site
|
||||
# 接入)记入 client_info 作会话站点标——只加记录,不参与业务逻辑
|
||||
site_slug = str(init_msg.get("site") or "").strip()
|
||||
if site_slug:
|
||||
client_info["site"] = site_slug
|
||||
|
||||
if "session_id" in init_msg:
|
||||
conv = await manager.get_by_session(init_msg["session_id"])
|
||||
if conv and conv.status == ConversationStatus.active:
|
||||
|
||||
@@ -47,6 +47,12 @@ async def chat_session(req: SessionRequest, request: Request):
|
||||
if xf:
|
||||
client_info["ip"] = xf
|
||||
|
||||
# Task 10: ?site=<slug> 记入 client_info 作会话站点标(与 WS init 的
|
||||
# site 字段同语义)——只加记录,不参与业务逻辑
|
||||
site_slug = request.query_params.get("site", "").strip()
|
||||
if site_slug:
|
||||
client_info["site"] = site_slug
|
||||
|
||||
# Task 5b: 租户运行时经 state/默认租户兜底解析(过渡期无中间件);
|
||||
# manager 由装配线从 runtime 造(db 会话 + runtime 引用)
|
||||
runtime = get_runtime(request)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""公开品牌端点(Task 10)——widget/独立页/app 的品牌唯一事实源。
|
||||
|
||||
纪律:
|
||||
- **显式字段白名单**序列化(BRAND_FIELDS 常量逐字段取值)——绝不序列化
|
||||
整个 site.brand/config:api_key/smtp 等内部字段无出现在响应里的可能
|
||||
(tests/test_brand_endpoint.py 白名单断言钉死);
|
||||
- 响应头 ``Access-Control-Allow-Origin: *``:挂件嵌在客户站,brand 与
|
||||
avatar 都是跨源加载;
|
||||
- ``?site=<slug>`` 站点覆盖:空域名站点(如 jiancai 的 es)靠嵌入代码
|
||||
``data-site`` 接入,未知/缺省回退中间件按 Host 解析出的 state.site
|
||||
(即租户默认门面);
|
||||
- avatar 按当前租户解析 ``seed_profiles/<slug>/assets/<brand.avatar 文件名>``,
|
||||
文件不存在回退 ``static/kefu-small.png``(两租户头像各归各,字节级不同)。
|
||||
|
||||
中间件已保证到达路由的请求必有 state.tenant/state.site;本路由照常经
|
||||
中间件(租户域名下访问),经 get_runtime 取运行时。
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from app.engine.config import STATIC_DIR
|
||||
from app.engine.routes.runtime import get_runtime
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public-brand"])
|
||||
|
||||
# 白名单字段(与 tests/test_brand_endpoint.BRAND_EXPECTED 逐字同步):
|
||||
# 增删字段必须同时改测试——"绝不多吐"是本端点的红线
|
||||
BRAND_FIELDS = ("slug", "title", "color", "avatar", "welcome", "topics",
|
||||
"placeholder", "whatsapp", "email", "privacy_url")
|
||||
|
||||
_AVATAR_FALLBACK = STATIC_DIR / "kefu-small.png"
|
||||
|
||||
|
||||
def _effective_site(request: Request, runtime):
|
||||
"""?site=<slug> 覆盖 state.site(未知 slug 回退),返回 (site, 是否命中覆盖)。"""
|
||||
site_param = (request.query_params.get("site") or "").strip()
|
||||
if not site_param:
|
||||
return request.state.site, False
|
||||
overridden = runtime.site_by_slug(site_param)
|
||||
if overridden is None:
|
||||
return request.state.site, False
|
||||
return overridden, True
|
||||
|
||||
|
||||
@router.get("/brand")
|
||||
async def brand(request: Request):
|
||||
runtime = get_runtime(request)
|
||||
site, site_hit = _effective_site(request, runtime)
|
||||
b = site.brand
|
||||
payload = {
|
||||
# slug = 租户 slug:客户端据此拼租户化 key(host id / storage key)
|
||||
"slug": runtime.config.slug,
|
||||
"title": b.get("title", ""),
|
||||
"color": b.get("color", ""),
|
||||
# avatar 是本端点的配套 URL(可能带 ?site= 让头像跟随站点覆盖),
|
||||
# 客户端拼 origin 后直接加载
|
||||
"avatar": "/api/public/avatar" + ("?site=" + site.slug if site_hit else ""),
|
||||
"welcome": b.get("welcome", ""),
|
||||
"topics": list(b.get("topics") or []),
|
||||
"placeholder": b.get("placeholder", ""),
|
||||
"whatsapp": b.get("whatsapp", ""),
|
||||
"email": b.get("email", ""),
|
||||
"privacy_url": b.get("privacy_url", ""),
|
||||
}
|
||||
assert set(payload.keys()) == set(BRAND_FIELDS) # 白名单自检(防手滑加字段)
|
||||
return JSONResponse(payload, headers={"Access-Control-Allow-Origin": "*"})
|
||||
|
||||
|
||||
@router.get("/avatar")
|
||||
async def avatar(request: Request):
|
||||
runtime = get_runtime(request)
|
||||
site, _ = _effective_site(request, runtime)
|
||||
# 只取文件名(PurePosixPath.name 剥掉一切路径成分)——brand.avatar 是
|
||||
# "/static/kefu-small.png" 这类路径,取基名后拼租户 assets 目录,无路径
|
||||
# 逃逸面(单成分文件名不可能越出 assets/)
|
||||
name = pathlib.PurePosixPath(str(site.brand.get("avatar") or "")).name
|
||||
candidate = runtime.seed_dir / "assets" / name if name else None
|
||||
if candidate is not None and candidate.is_file():
|
||||
return FileResponse(
|
||||
candidate, headers={"Access-Control-Allow-Origin": "*"})
|
||||
return FileResponse(
|
||||
_AVATAR_FALLBACK, headers={"Access-Control-Allow-Origin": "*"})
|
||||
Reference in New Issue
Block a user