feat: brand 白名单下发(站点覆盖/租户头像/CORS) + widget/chat 客户端 key 租户化 + 会话站点标

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 22:26:15 +08:00
parent 49e2b476f5
commit bbc7bc520e
15 changed files with 570 additions and 93 deletions
+6
View File
@@ -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:
+6
View File
@@ -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)
+84
View File
@@ -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": "*"})
+2
View File
@@ -56,8 +56,10 @@ app = FastAPI(title="HESUPIPE AI 客服", lifespan=lifespan)
from app.engine.routes.chat import router as chat_router
from app.engine.routes.chat_rest import router as chat_rest_router
from app.engine.routes.public import router as public_router
app.include_router(chat_router)
app.include_router(chat_rest_router)
app.include_router(public_router)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+3 -1
View File
@@ -13,7 +13,9 @@
"title": "HESU Support",
"color": "#f08a31",
"whatsapp": "+8619037179250",
"email": "info@borderlesscar.com"
"email": "info@borderlesscar.com",
"welcome": "Hey! What kind of car are you looking for?",
"topics": ["Get Quotation", "Distributor Cooperation"]
},
"push": {
"prefix": "ai-kefu"
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+3 -1
View File
@@ -22,7 +22,9 @@
"color": "#066fc0",
"whatsapp": "+8615937108317",
"email": "hesu@hesuu.com",
"privacy_url": "https://hesupipe.com/privacy-policy"
"privacy_url": "https://hesupipe.com/privacy-policy",
"welcome": "What kind of piping project are you working on?",
"topics": ["Get Quotation", "Pipe Specifications", "Delivery & Shipping"]
},
"push": {
"prefix": "ai-kefu-jc"
+11 -13
View File
@@ -3,16 +3,16 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>HESUPIPE Support</title>
<title>AI Support</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="chat-container">
<div class="chat-header">
<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="/static/kefu-small.png"></div>
<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="/api/public/avatar"></div>
<div class="info">
<h1>HESUPIPE Support</h1>
<h1>AI Support</h1>
<div class="availability">Online now - Usually replies in minutes</div>
</div>
<!-- <div class="actions">
@@ -33,11 +33,13 @@
<div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div>
</div>
<div class="help-actions">
<a href="https://wa.me/+8615937108317" target="_blank" class="call-btn whatsapp-btn">
<!-- Task 10: 联系按钮/标题/欢迎语/话题/隐私链接由 chat.js 从
/api/public/brand 填充(值缺位隐藏),出厂不再内嵌租户品牌 -->
<a target="_blank" rel="noopener" class="call-btn whatsapp-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>
<span>WhatsApp</span>
</a>
<a href="mailto:hesu@hesuu.com" class="call-btn email-btn">
<a class="call-btn email-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
<span>Email</span>
</a>
@@ -48,7 +50,7 @@
<div class="chat-separator"></div>
<div class="chat-messages" id="messages">
<div class="message assistant">
<div class="bubble">What kind of piping project are you working on?</div>
<div class="bubble"></div>
</div>
</div>
@@ -56,14 +58,10 @@
<span></span><span></span><span></span>
</div>
<!-- POPULAR TOPICS 区块 -->
<!-- POPULAR TOPICS 区块(按钮由 chat.js 按 brand.topics 生成) -->
<div class="popular-topics-fixed" id="popular-topics">
<div class="popular-topics-label">POPULAR TOPICS</div>
<div class="popular-topics-grid">
<button class="popular-topic-btn" data-topic="Get Quotation">Get Quotation</button>
<button class="popular-topic-btn" data-topic="Pipe Specifications">Pipe Specifications</button>
<button class="popular-topic-btn" data-topic="Delivery & Shipping">Delivery &amp; Shipping</button>
</div>
<div class="popular-topics-grid"></div>
</div>
<div class="chat-separator-topics"></div>
@@ -76,7 +74,7 @@
</button>
</div>
</div>
<div class="chat-footer"><span class="footer-left">By chatting, you agree to our <a href="https://hesupipe.com/privacy-policy/" target="_blank" rel="noopener">privacy policy</a>.</span></div>
<div class="chat-footer"><span class="footer-left">By chatting, you agree to our <a target="_blank" rel="noopener">privacy policy</a>.</span></div>
</div>
<script src="/static/js/chat.js"></script>
+103 -25
View File
@@ -1,20 +1,25 @@
// ---- 动态主题色 bootstrap(spec §3):?color= → 派生 → :root 变量 ----
// ---- Task 10: brand 拉取(同源 /api/public/brand,下方两个 IIFE 共用) ----
// 失败/超时(3s) → resolve(null) → 中性兜底,页面照常可用不白屏。
var chatBrandReady = (function () {
try {
var p = fetch("/api/public/brand", { cache: "no-store" }).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () {
return null;
});
return Promise.race([p, new Promise(function (res) { setTimeout(function () { res(null); }, 3000); })]);
} catch (e) {
return Promise.resolve(null); // 无 fetch 的远古环境:中性兜底
}
})();
// ---- 动态主题色 bootstrap(spec §3):派生 → :root 变量 ----
// Task 10 颜色优先级: ?color=(URL 显式覆盖,语义同挂件 data-*) >
// brand.color(租户/站点门面) > DEFAULT_COLOR。
(function () {
if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 品牌蓝兜底
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return ""; } }
var color = m ? safeDecode(m[1]).trim() : "";
var palette = ChatTheme.derivePalette(color || ChatTheme.DEFAULT_COLOR);
var root = document.documentElement.style;
root.setProperty("--w-primary", palette.primary);
root.setProperty("--w-primary-rgb", palette.primaryRgb);
root.setProperty("--w-gradient-end", palette.gradientEnd);
root.setProperty("--w-deep-text", palette.deepText);
root.setProperty("--w-caption-text", palette.captionText);
root.setProperty("--w-soft-bg", palette.softBg);
root.setProperty("--w-border-soft", palette.borderSoft);
// 头像兜底(与挂件同机制)
// 头像兜底(与挂件同机制)——与 brand 无关,立即挂
Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) {
function hideEmoji() {
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
@@ -24,18 +29,31 @@
img.addEventListener("load", hideEmoji);
img.addEventListener("error", function () { img.remove(); });
});
chatBrandReady.then(function (b) {
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return ""; } }
var color = m ? safeDecode(m[1]).trim() : (((b && b.color) || "").trim());
var palette = ChatTheme.derivePalette(color || ChatTheme.DEFAULT_COLOR);
var root = document.documentElement.style;
root.setProperty("--w-primary", palette.primary);
root.setProperty("--w-primary-rgb", palette.primaryRgb);
root.setProperty("--w-gradient-end", palette.gradientEnd);
root.setProperty("--w-deep-text", palette.deepText);
root.setProperty("--w-caption-text", palette.captionText);
root.setProperty("--w-soft-bg", palette.softBg);
root.setProperty("--w-border-soft", palette.borderSoft);
});
})();
(function () {
// 联系方式配置(与 widget.js 的 data-* 机制一致)
// 联系方式配置(与 widget.js 的 data-* 机制一致:属性显式覆盖 > brand)
var chatScript = document.currentScript || document.querySelector('script[src*="chat.js"]');
var whatsapp = (chatScript && chatScript.getAttribute("data-whatsapp")) || "https://wa.me/+8615937108317";
var email = (chatScript && chatScript.getAttribute("data-email")) || "mailto:hesu@hesuu.com";
var whatsappAttr = chatScript && chatScript.getAttribute("data-whatsapp");
var emailAttr = chatScript && chatScript.getAttribute("data-email");
var waBtn = document.querySelector('.whatsapp-btn');
var emBtn = document.querySelector('.email-btn');
if (waBtn) waBtn.href = whatsapp;
if (emBtn) emBtn.href = email;
var sessionId = localStorage.getItem("cc_session_id");
var sessionId = null;
var messagesEl = document.getElementById("messages");
var inputEl = document.getElementById("input");
var sendBtn = document.getElementById("sendBtn");
@@ -49,13 +67,63 @@
var streamBubble = null;
var statusTimer = null;
function contactHref(value, kind) {
// brand 存裸值(电话/邮箱),data-* 历史上传完整 URL——两者都接受
if (!value) return "";
if (/^(https?:|mailto:)/i.test(value)) return value;
return kind === "wa" ? "https://wa.me/" + value : "mailto:" + value;
}
function applyBrand(b) {
b = (b && typeof b === "object") ? b : {};
if (b.title) {
document.title = b.title;
var h1 = document.querySelector(".chat-header .info h1");
if (h1) h1.textContent = b.title;
}
var wa = contactHref(whatsappAttr || b.whatsapp || "", "wa");
if (waBtn) { if (wa) waBtn.href = wa; else waBtn.style.display = "none"; }
var em = contactHref(emailAttr || b.email || "", "mail");
if (emBtn) { if (em) emBtn.href = em; else emBtn.style.display = "none"; }
if (b.placeholder) inputEl.placeholder = b.placeholder;
if (b.welcome) {
var bubble = document.querySelector("#messages .message .bubble");
if (bubble) bubble.textContent = b.welcome;
}
if (Array.isArray(b.topics) && b.topics.length) {
var grid = document.querySelector(".popular-topics-grid");
if (grid) {
grid.innerHTML = "";
b.topics.forEach(function (t) {
if (typeof t !== "string" || !t) return;
var btn = document.createElement("button");
btn.className = "popular-topic-btn";
btn.setAttribute("data-topic", t);
btn.textContent = t;
btn.addEventListener("click", function () { sendTopicMessage(t); });
grid.appendChild(btn);
});
}
} else if (popularTopicsEl) {
popularTopicsEl.style.display = "none"; // 无话题配置:整区隐藏
var sep = document.querySelector(".chat-separator-topics");
if (sep) sep.style.display = "none"; // 分隔线随区隐藏,不留孤线
}
var footer = document.querySelector(".chat-footer");
if (footer) {
var link = footer.querySelector("a");
if (b.privacy_url && link) link.href = b.privacy_url;
else footer.style.display = "none"; // GDPR 行:为空即隐藏
}
}
function getWsUrl() {
var loc = window.location;
var proto = loc.protocol === "https:" ? "wss:" : "ws:";
return proto + "//" + loc.host + "/ws/chat";
}
function connect() {
function connect(sessionKey) {
ws = new WebSocket(getWsUrl());
ws.onopen = function () {
@@ -71,7 +139,7 @@
if (data.type === "init" || data.type === "resumed") {
sessionId = data.session_id;
localStorage.setItem("cc_session_id", sessionId);
localStorage.setItem(sessionKey, sessionId);
if (data.type === "resumed") {
loadHistory();
}
@@ -113,7 +181,7 @@
ws.onclose = function () {
if (!ended) {
setTimeout(connect, 3000);
setTimeout(function () { connect(sessionKey); }, 3000);
}
};
@@ -218,7 +286,8 @@
}
});
// Popular topics buttons
// Popular topics buttons(出厂模板的兜底话题;brand 下发后由 applyBrand
// 重建,这里的绑定覆盖"brand 失败"路径)
var topicBtns = document.querySelectorAll(".popular-topic-btn");
topicBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
@@ -227,5 +296,14 @@
});
});
connect();
// Task 10: 会话 key 租户化(cc_session_id_<slug>)——brand 落定(成功或
// 失败,slug 回退 "default")后才读 key/连 WS,保证同浏览器多租户互不串
chatBrandReady.then(function (b) {
applyBrand(b);
var slug = String((b && b.slug) || "default").replace(/[^a-zA-Z0-9_-]/g, "") || "default";
var sessionKey = "cc_session_id_" + slug;
sessionId = null;
try { sessionId = localStorage.getItem(sessionKey); } catch (e) { /* storage 不可用 */ }
connect(sessionKey);
});
})();
+34 -7
View File
@@ -83,7 +83,7 @@
<div class="info">
<p><strong>Current Page:</strong> <span id="pathname"></span></p>
<p><strong>Storage Key:</strong> widget_invite_dismissed_<span id="storage-key"></span></p>
<p><strong>Storage Key:</strong> widget_invite_dismissed_&lt;slug&gt;_<span id="storage-key"></span> (Task 10: key 按租户 slug 命名空间化)</p>
<p><button class="btn" onclick="clearStorage()">Clear Storage &amp; Refresh</button></p>
</div>
@@ -92,7 +92,12 @@
document.getElementById('storage-key').textContent = window.location.pathname;
function clearStorage() {
sessionStorage.removeItem('widget_invite_dismissed_' + window.location.pathname);
// Task 10: key 已租户化(widget_invite_dismissed_<slug>_<path>)——按前缀清扫全部
try {
Object.keys(sessionStorage).forEach(function (k) {
if (k.indexOf('widget_invite_dismissed_') === 0) sessionStorage.removeItem(k);
});
} catch (e) {}
location.reload();
}
@@ -107,22 +112,36 @@
};
var rebuildLoading = false, rebuildPending = null;
function removeWidgetHosts() {
// Task 10: host id 已租户化(cc-widget-host-<slug>),按前缀清扫
var hosts = document.querySelectorAll('[id^="cc-widget-host-"]');
for (var i = 0; i < hosts.length; i++) hosts[i].remove();
}
function rebuildWidget(extra) {
if (rebuildLoading) { rebuildPending = extra || {}; return; } // 快速连点:只记最新意图,当前加载完接力重建
rebuildLoading = true;
document.getElementById("assertions").innerHTML = "<div>…rebuilding…</div>";
document.querySelectorAll("#cc-widget-host").forEach(function (h) { h.remove(); });
removeWidgetHosts();
document.querySelectorAll("script[src*='widget.js']").forEach(function (s) { s.remove(); });
try { sessionStorage.removeItem("widget_invite_dismissed_" + window.location.pathname); } catch (e) {}
try {
Object.keys(sessionStorage).forEach(function (k) {
if (k.indexOf("widget_invite_dismissed_") === 0) sessionStorage.removeItem(k);
});
} catch (e) {}
var s = document.createElement("script");
s.src = "/static/widget.js?" + Date.now();
var attrs = Object.assign({}, BASE_ATTRS, extra || {});
Object.keys(attrs).forEach(function (k) { s.setAttribute(k, attrs[k]); });
s.onload = function () {
rebuildLoading = false;
var hosts = document.querySelectorAll("#cc-widget-host");
for (var i = 0; i < hosts.length - 1; i++) hosts[i].remove(); // 竞态兜底:只保留最后挂载
runAssertions();
// Task 10: 挂件改等 brand 端点落定后才挂载——脚本 onload 早于挂载,
// 轮询 __chatWidgetDebug(boot 里同步设置,观察到场即 init 已完成)
// 就绪再清竞态残留并跑断言
waitForWidget(function () {
var hosts = document.querySelectorAll('[id^="cc-widget-host-"]');
for (var i = 0; i < hosts.length - 1; i++) hosts[i].remove(); // 竞态兜底:只保留最后挂载
runAssertions(); // 挂件未就绪(超时)时其内部 !d 分支自行报红
});
var next = rebuildPending; rebuildPending = null;
if (next) rebuildWidget(next);
};
@@ -134,6 +153,14 @@
};
document.body.appendChild(s);
}
function waitForWidget(cb) {
var started = Date.now();
(function poll() {
if (window.__chatWidgetDebug) return cb(true);
if (Date.now() - started > 5000) return cb(false); // brand 挂起/失败兜底
setTimeout(poll, 100);
})();
}
function applyColor(c) {
rebuildWidget({ "data-color": c || document.getElementById("colorPicker").value });
}
+133 -43
View File
@@ -5,12 +5,18 @@
})();
// Save config immediately (before DOM ready)
// Task 10 值管道: data-* 属性(嵌入方显式覆盖,最高优先) > brand 端点
// (租户/站点门面) > 中性兜底。此处只同步读原始属性;有效值在 brand
// 落定后由 boot() 推导(见文件尾),品牌文案不再硬编码于本文件。
var position = script.getAttribute("data-position") || "bottom-right";
var theme = script.getAttribute("data-theme") || "light";
var title = script.getAttribute("data-title") || "HESUPIPE Support";
var titleAttr = script.getAttribute("data-title");
var autoOpen = script.getAttribute("data-auto-open") === "true";
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8615937108317";
var email = script.getAttribute("data-email") || "mailto:hesu@hesuu.com";
var whatsappAttr = script.getAttribute("data-whatsapp");
var emailAttr = script.getAttribute("data-email");
var colorAttr = (script.getAttribute("data-color") || "").trim();
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
var siteAttr = (script.getAttribute("data-site") || "").trim();
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var isMobile = script.getAttribute("data-mobile") === "true";
var wsBase = new URL(script.src).origin;
@@ -109,14 +115,62 @@
/* ===== 主题派生单元结束 ===== */
var DEFAULT_AVATAR_PATH = "/static/kefu-small.png";
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
var avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
: wsBase + DEFAULT_AVATAR_PATH;
var colorAttr = (script.getAttribute("data-color") || "").trim();
var palette = derivePalette(colorAttr || DEFAULT_COLOR);
if (script.getAttribute("data-debug") === "true") {
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
// ---- Task 10: brand 端点取门面 + 有效值推导 ------------------------------
// __brand = /api/public/brand 响应(null = 拉取失败/超时 → 中性兜底,不白屏)
var __brand = null;
var title = "", whatsapp = "", email = "", avatarUrl = "", privacyUrl = "";
var welcome = "", placeholder = "Type a message...", topics = [];
var palette = null;
var brandSlug = "default";
function fetchBrand() {
var url = wsBase + "/api/public/brand" + (siteAttr ? "?site=" + encodeURIComponent(siteAttr) : "");
var p = fetch(url, { cache: "no-store" }).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () {
return null; // fetch 失败 → data-*/中性兜底,挂件照常渲染
});
// 挂起保护: brand 端点 3s 无响应按失败处理,网络问题不挡挂件挂载
return Promise.race([p, new Promise(function (res) { setTimeout(function () { res(null); }, 3000); })]);
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
function contactHref(value, kind) {
// brand 存裸值(电话/邮箱),data-* 历史上传完整 URL——两者都接受
if (!value) return "";
if (/^(https?:|mailto:)/i.test(value)) return value;
return kind === "wa" ? "https://wa.me/" + value : "mailto:" + value;
}
// boot: brand 落定(成功或失败)后推导有效值再挂载。只改"默认值从哪来",
// 不触碰上方主题派生单元(调色板算法零改动,只换输入)。
function boot() {
var b = (__brand && typeof __brand === "object") ? __brand : {};
title = titleAttr || b.title || "Chat with us";
whatsapp = contactHref(whatsappAttr || b.whatsapp || "", "wa");
email = contactHref(emailAttr || b.email || "", "mail");
palette = derivePalette(colorAttr || (b.color || "").trim() || DEFAULT_COLOR);
avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
: (b.avatar ? wsBase + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar)
: wsBase + DEFAULT_AVATAR_PATH);
welcome = b.welcome || "";
topics = Array.isArray(b.topics)
? b.topics.filter(function (t) { return typeof t === "string" && t; })
: [];
placeholder = b.placeholder || "Type a message...";
privacyUrl = b.privacy_url || "";
brandSlug = String(b.slug || "default").replace(/[^a-zA-Z0-9_-]/g, "") || "default";
if (script.getAttribute("data-debug") === "true") {
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
}
init();
}
var positionStyles = {
@@ -146,7 +200,7 @@
function init() {
var host = document.createElement("div");
host.id = "cc-widget-host";
host.id = "cc-widget-host-" + brandSlug;
document.body.appendChild(host);
var shadow = host.attachShadow({ mode: "open" });
@@ -300,32 +354,61 @@
var container = document.createElement("div");
container.className = "widget-container pos-" + position + (isMobile ? " mobile-host" : "");
// Task 10: 品牌/联系/隐私/话题分块——值缺位(data-* 与 brand 都没给)则整块
// 不渲染(隐私行隐藏、联系按钮隐藏、话题区隐藏),不再硬编码任何租户文案
var waBtnHtml = whatsapp
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a>'
: "";
var emBtnHtml = email
? '<a href="' + esc(email) + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a>'
: "";
var privacyLineHtml = privacyUrl
? 'By chatting, you agree to our <a href="' + esc(privacyUrl) + '" target="_blank" rel="noopener">privacy policy</a>.'
: "";
var chatFooterHtml = privacyLineHtml
? '<div class="chat-footer"><span class="footer-left">' + privacyLineHtml + '</span></div>'
: "";
var invitePrivacyHtml = privacyLineHtml
? '<div class="invite-privacy">' + privacyLineHtml + '</div>'
: "";
var topicsBtnsHtml = topics.map(function (t) {
return '<button class="popular-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
}).join("");
var inviteTopicsHtml = topics.map(function (t) {
return '<button class="invite-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
}).join("");
var popularTopicsSectionHtml = topics.length
? '<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid">' + topicsBtnsHtml + '</div></div><div class="chat-separator-topics"></div>'
: "";
var welcomeMsgHtml = welcome
? '<div class="msg-wrapper"><div class="msg ai">' + esc(welcome) + '</div></div>'
: "";
var panel = document.createElement("div");
panel.className = "chat-panel";
panel.innerHTML = [
'<div class="panel-header">',
'<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"></div>',
'<div class="info"><h3>' + title + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
'<div class="info"><h3>' + esc(title) + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
'<div class="actions">',
'<button class="action-btn" aria-label="Minimize">−</button>',
// '<button class="close-btn action-btn" aria-label="Close">×</button>',
'</div></div>',
'<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>',
'<div class="panel-top">',
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions"><a href="' + whatsapp + '" target="_blank" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a><a href="' + email + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a></div></div></div>',
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions">' + waBtnHtml + emBtnHtml + '</div></div></div>',
'</div>',
'<div class="chat-separator"></div>',
'<div class="panel-messages" id="w-msgs">',
'<div class="msg-wrapper"><div class="msg ai">What kind of piping project are you working on?</div></div>',
welcomeMsgHtml,
'</div>',
'<div class="typing" id="w-typing"><span></span><span></span><span></span></div>',
'<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid"><button class="popular-topic-btn" data-topic="Get Quotation">Get Quotation</button><button class="popular-topic-btn" data-topic="Pipe Specifications">Pipe Specifications</button><button class="popular-topic-btn" data-topic="Delivery &amp; Shipping">Delivery &amp; Shipping</button></div></div>',
'<div class="chat-separator-topics"></div>',
popularTopicsSectionHtml,
'<div class="input-container"><div class="input-wrapper">',
'<input type="text" id="w-input" placeholder="Type a message...">',
'<input type="text" id="w-input" placeholder="' + esc(placeholder) + '">',
'<button id="w-send"><span style="font-size:16px;">➤</span><span>Send</span></button>',
'</div></div>',
'<div class="chat-footer"><span class="footer-left">By chatting, you agree to our <a href="https://hesupipe.com/privacy-policy/" target="_blank" rel="noopener">privacy policy</a>.</span></div>',
chatFooterHtml,
].join("");
// Invite card HTML template
@@ -342,23 +425,23 @@
'<button class="invite-close-btn" aria-label="Close">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
'</button>',
'<a href="' + whatsapp + '" target="_blank" class="invite-action-btn whatsapp-btn">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>',
'<span>WhatsApp</span>',
'</a>',
'<a href="' + email + '" class="invite-action-btn email-btn">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>',
'<span>Email</span>',
'</a>',
'</div>',
'</div>',
'<div class="invite-body">',
'<div class="invite-topics">',
'<button class="invite-topic-btn" data-topic="Get Quotation">Get Quotation</button>',
'<button class="invite-topic-btn" data-topic="Pipe Specifications">Pipe Specifications</button>',
'<button class="invite-topic-btn" data-topic="Delivery &amp; Shipping">Delivery &amp; Shipping</button>',
(whatsapp
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="invite-action-btn whatsapp-btn">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>'
+ '<span>WhatsApp</span></a>'
: ""),
(email
? '<a href="' + esc(email) + '" class="invite-action-btn email-btn">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>'
+ '<span>Email</span></a>'
: ""),
'</div>',
'</div>',
(topics.length
? '<div class="invite-body">'
+ '<div class="invite-topics">' + inviteTopicsHtml + '</div>'
+ '</div>'
: ""),
'<div class="chat-separator"></div>',
'<div class="input-container">',
'<div class="invite-actions">',
@@ -367,7 +450,7 @@
'</button>',
'<button class="invite-dismiss-btn">No Thanks</button>',
'</div>',
'<div class="invite-privacy">By chatting, you agree to our <a href="https://hesupipe.com/privacy-policy/" target="_blank" rel="noopener">privacy policy</a>.</div>',
invitePrivacyHtml,
'</div>',
'</div>'
].join("");
@@ -429,7 +512,8 @@
var inviteTimer = null;
var invitePanel = null;
var inviteShown = false;
var inviteStorageKey = "widget_invite_dismissed_" + window.location.pathname;
// Task 10: key 租户化——同浏览器多租户挂件互不踩(不同租户 slug 隔离)
var inviteStorageKey = "widget_invite_dismissed_" + brandSlug + "_" + window.location.pathname;
var isPermanentlyClosed = false;
function checkInviteStorage() {
@@ -597,10 +681,12 @@
wWs = new WebSocket(wsHost + "/ws/chat");
wWs.onopen = function () {
// Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端
// 记入 client_info["site"] 作会话站点标;空值服务端忽略)
if (wSessionId) {
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken }));
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken, site: siteAttr }));
} else {
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken }));
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken, site: siteAttr }));
}
// Send any pending message after WebSocket opens
if (pendingMessage) {
@@ -696,10 +782,14 @@
startInviteTimer();
}
// Wait for body if script is in <head>
if (document.body) {
init();
} else {
document.addEventListener("DOMContentLoaded", init);
// Wait for body if script is in <head>; Task 10: 同时等 brand 落定(成功或
// 失败都继续)再挂载——两条件齐备才 init,任一失败路径都不白屏
function domReady(fn) {
if (document.body) fn();
else document.addEventListener("DOMContentLoaded", fn);
}
fetchBrand().then(function (b) {
__brand = b;
domReady(boot);
});
})();
+46
View File
@@ -0,0 +1,46 @@
"""Task 10: /api/public/brand + /api/public/avatar——品牌白名单下发。
红线:
- 品牌端点**显式字段白名单**序列化(BRAND_FIELDS)——绝不序列化整个
config(api_key/smtp 等内部字段永不出现在响应);
- 响应头 Access-Control-Allow-Origin: *(挂件嵌在客户站,跨源加载);
- ?site=<slug> 站点覆盖(空域名站点如 es 靠嵌入代码 data-site 接入),
未知 site 回退租户默认门面;
- 头像按租户解析 seed_profiles/<slug>/assets/,两租户各归各。
"""
BRAND_EXPECTED = {"slug", "title", "color", "avatar", "welcome", "topics",
"placeholder", "whatsapp", "email", "privacy_url"}
def test_brand_whitelist_only(app_client):
r = app_client.get("/api/public/brand")
assert r.status_code == 200
assert set(r.json().keys()) == BRAND_EXPECTED # 白名单,绝不多吐
assert "api_key" not in r.text and "smtp" not in r.text.lower()
assert r.headers["access-control-allow-origin"] == "*"
def test_brand_site_overlay(app_client):
h = {"host": "chat2.agent.hnxinyouhe.com"}
r = app_client.get("/api/public/brand?site=es", headers=h)
assert r.json()["title"] == "HESUPIPE Soporte" # 站点覆盖生效
r2 = app_client.get("/api/public/brand", headers=h)
assert r2.json()["title"] == "HESUPIPE Support" # 不带 site = 租户默认门面
r3 = app_client.get("/api/public/brand?site=nope", headers=h)
assert r3.json()["title"] == "HESUPIPE Support" # 未知 site 回退
def test_brand_carries_tenant_slug(app_client):
# slug 供客户端拼租户化 key(host id / storage key),jiancai 域必回 jiancai
r = app_client.get("/api/public/brand")
assert r.json()["slug"] == "jiancai"
def test_avatar_served_per_tenant(app_client):
r = app_client.get("/api/public/avatar", headers={"host": "chat.agent.hnxinyouhe.com"})
assert r.status_code == 200 and r.headers["content-type"].startswith("image/")
# 两租户头像内容不同(字节级对比两租户的 avatar 响应)
r_jc = app_client.get("/api/public/avatar") # base_url = jiancai 域
assert r_jc.status_code == 200
assert r.content != r_jc.content
+12 -3
View File
@@ -73,13 +73,22 @@ def test_autoinvite_specialcase_removed_and_guard_kept():
def test_gdpr_privacy_notice_present():
"""GDPR 三入口结构锁(Task 9 质量审查): 邀请卡/聊天面板/独立页的
privacy 提示是纯模板字符串,无此断言则重构时会无声丢失。"""
privacy 提示是纯模板字符串,无此断言则重构时会无声丢失。
等价迁移(Task 10): 隐私链接原锚 widget.js/chat.html 硬编码
hesupipe.com URL——品牌值已迁 seed_profiles/*/tenant.json 的
brand.privacy_url,经 /api/public/brand 下发后由 JS 填 href(为空隐藏)。
断言语义等价: 仍锁"三入口结构 + 隐私链接出自 brand.privacy_url"机制,
值锚定改由 test_widget_namespacing.test_brand_values_live_in_seed_profiles
承担(jiancai privacy_url = hesupipe.com/privacy-policy)。"""
wjs = _read("widget.js")
assert "invite-privacy" in wjs and "chat-footer" in wjs
assert 'href="https://hesupipe.com/privacy-policy/"' in wjs
assert "privacy_url" in wjs # 挂件隐私链接读 brand(Task 10 迁移点)
assert 'target="_blank" rel="noopener"' in wjs
chat = _read("chat.html")
assert "privacy policy" in chat and "privacy-policy/" in chat
assert "privacy policy" in chat # 三入口文案仍在出厂模板
cjs = _read("js/chat.js")
assert "privacy_url" in cjs # 独立页 href 由 brand 填(原锚 chat.html 硬编码 URL)
def test_position_class_panel_alignment():
+127
View File
@@ -0,0 +1,127 @@
"""Task 10 静态+路由回归网:客户端 key 租户化 / brand fetch 机制 / 会话站点标。
- 静态断言仿 test_theme_static 风格(Path(__file__) 锚定仓库静态文件);
- 品牌值已自 widget.js/chat.js/chat.html 迁入 seed_profiles/*/tenant.json
(断言语义等价迁移:锚 seed_profiles 值或锚 fetch 机制);
- 服务端配套:WS init 的 site 与 REST ?site= 记入 client_info["site"]。
"""
import asyncio
import json
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.pool import NullPool
from app.engine.models import Conversation
REPO = Path(__file__).resolve().parents[1]
STATIC = REPO / "static"
SEED = REPO / "seed_profiles"
def _read(rel):
return (STATIC / rel).read_text(encoding="utf-8")
# ---- 客户端 key 租户化 ---------------------------------------------------------
def test_widget_keys_namespaced_by_slug():
src = _read("widget.js")
assert "cc-widget-host-" in src # host id 拼租户 slug(模板串形式)
assert "widget_invite_dismissed_" in src # sessionStorage key 同
def test_chat_session_key_namespaced_by_slug():
src = _read("js/chat.js")
assert "cc_session_id_" in src # 独立页 localStorage 会话 key 同
# ---- brand fetch 机制(迁移后品牌事实源 = seed_profiles)------------------------
def test_widget_fetches_brand_with_site_param():
src = _read("widget.js")
assert "/api/public/brand" in src # 启动即取品牌
assert "?site=" in src # data-site 进 query(空域名站点接入)
assert "no-store" in src # 品牌热更新即时生效
def test_widget_ws_init_carries_site():
src = _read("widget.js")
assert "site: siteAttr" in src # WS init 消息带 site
def test_brand_values_live_in_seed_profiles():
"""等价迁移锚:原锚 widget.js 硬编码品牌文案的用例改锚 seed_profiles 值
(品牌事实源自 Task 10 起迁入 tenant.json)。"""
jc = json.loads((SEED / "jiancai" / "tenant.json").read_text(encoding="utf-8"))
b = jc["config"]["brand"]
assert b["title"] == "HESUPIPE Support"
assert b["whatsapp"] == "+8615937108317"
assert b["email"] == "hesu@hesuu.com"
assert b["privacy_url"] == "https://hesupipe.com/privacy-policy"
assert b["topics"] == ["Get Quotation", "Pipe Specifications", "Delivery & Shipping"]
es = next(s for s in jc["sites"] if s["slug"] == "es")
assert es["brand"]["title"] == "HESUPIPE Soporte"
auto = json.loads((SEED / "auto" / "tenant.json").read_text(encoding="utf-8"))
ab = auto["config"]["brand"]
assert ab["title"] == "HESU Support"
assert ab["whatsapp"] == "+8619037179250"
assert ab["topics"] == ["Get Quotation", "Distributor Cooperation"]
def test_no_brand_copy_left_in_client_files():
"""等价迁移锚:三份客户端文件不再内嵌任何租户品牌值(联系号/邮箱/品牌名/
隐私 URL),品牌只经 /api/public/brand 下发。"""
for rel in ("widget.js", "js/chat.js", "chat.html"):
src = _read(rel)
assert "HESUPIPE" not in src, f"{rel} 残留品牌名"
assert "8615937108317" not in src and "8619037179250" not in src, f"{rel} 残留联系号"
assert "hesuu.com" not in src and "borderlesscar.com" not in src, f"{rel} 残留邮箱域名"
assert "hesupipe.com" not in src, f"{rel} 残留隐私 URL 域名"
# ---- 服务端配套:会话打站点标 ---------------------------------------------------
def _read_client_info(db_path: Path, session_id: str) -> dict:
"""直读租户库验证 client_info——新开 NullPool engine(不复用 TestClient
循环里的 Registry engine,跨循环复用会死锁;同文件 WAL 多连接读安全)。"""
async def q():
engine = create_async_engine(
f"sqlite+aiosqlite:///{db_path.as_posix()}", poolclass=NullPool)
try:
async with AsyncSession(engine) as s:
conv = (await s.execute(
select(Conversation).where(
Conversation.session_id == session_id))).scalar_one()
return dict(conv.client_info or {})
finally:
await engine.dispose()
return asyncio.run(q())
def test_ws_init_site_recorded_in_client_info(app_client, tmp_path):
with app_client.websocket_connect("/ws/chat") as ws:
ws.send_text(json.dumps({"source": "widget", "site": "es"}))
sid = ws.receive_json()["session_id"]
assert _read_client_info(tmp_path / "jiancai.db", sid).get("site") == "es"
def test_ws_init_without_site_no_tag(app_client, tmp_path):
with app_client.websocket_connect("/ws/chat") as ws:
ws.send_text(json.dumps({"source": "widget"}))
sid = ws.receive_json()["session_id"]
assert "site" not in _read_client_info(tmp_path / "jiancai.db", sid)
def test_rest_session_site_query_recorded(app_client, tmp_path):
sid = app_client.post("/api/chat/session?site=es", json={}).json()["session_id"]
assert _read_client_info(tmp_path / "jiancai.db", sid).get("site") == "es"
def test_rest_session_without_site_no_tag(app_client, tmp_path):
sid = app_client.post("/api/chat/session", json={}).json()["session_id"]
assert "site" not in _read_client_info(tmp_path / "jiancai.db", sid)