diff --git a/app/engine/routes/chat.py b/app/engine/routes/chat.py index 3cecd44..e49494f 100644 --- a/app/engine/routes/chat.py +++ b/app/engine/routes/chat.py @@ -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: diff --git a/app/engine/routes/chat_rest.py b/app/engine/routes/chat_rest.py index d0482c7..8f87d07 100644 --- a/app/engine/routes/chat_rest.py +++ b/app/engine/routes/chat_rest.py @@ -47,6 +47,12 @@ async def chat_session(req: SessionRequest, request: Request): if xf: client_info["ip"] = xf + # Task 10: ?site= 记入 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) diff --git a/app/engine/routes/public.py b/app/engine/routes/public.py new file mode 100644 index 0000000..1f40b62 --- /dev/null +++ b/app/engine/routes/public.py @@ -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=`` 站点覆盖:空域名站点(如 jiancai 的 es)靠嵌入代码 + ``data-site`` 接入,未知/缺省回退中间件按 Host 解析出的 state.site + (即租户默认门面); +- avatar 按当前租户解析 ``seed_profiles//assets/``, + 文件不存在回退 ``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= 覆盖 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": "*"}) diff --git a/app/main.py b/app/main.py index 83d7c02..7174287 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/seed_profiles/auto/assets/kefu-small.png b/seed_profiles/auto/assets/kefu-small.png new file mode 100644 index 0000000..2f234e3 Binary files /dev/null and b/seed_profiles/auto/assets/kefu-small.png differ diff --git a/seed_profiles/auto/tenant.json b/seed_profiles/auto/tenant.json index 9d34699..64fb840 100644 --- a/seed_profiles/auto/tenant.json +++ b/seed_profiles/auto/tenant.json @@ -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" diff --git a/seed_profiles/jiancai/assets/kefu-small.png b/seed_profiles/jiancai/assets/kefu-small.png new file mode 100644 index 0000000..11cba74 Binary files /dev/null and b/seed_profiles/jiancai/assets/kefu-small.png differ diff --git a/seed_profiles/jiancai/tenant.json b/seed_profiles/jiancai/tenant.json index c1982f9..5dce3e7 100644 --- a/seed_profiles/jiancai/tenant.json +++ b/seed_profiles/jiancai/tenant.json @@ -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" diff --git a/static/chat.html b/static/chat.html index efdb9cf..afcb8d2 100644 --- a/static/chat.html +++ b/static/chat.html @@ -3,16 +3,16 @@ - HESUPIPE Support + AI Support
-
🤖
+
🤖
-

HESUPIPE Support

+

AI Support

Online now - Usually replies in minutes
+ WhatsApp - @@ -48,7 +50,7 @@
-
What kind of piping project are you working on?
+
@@ -56,14 +58,10 @@
- +
@@ -76,7 +74,7 @@
- + diff --git a/static/js/chat.js b/static/js/chat.js index 1b733b3..47180d8 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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_)——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); + }); })(); diff --git a/static/widget-test.html b/static/widget-test.html index 940b149..89512ea 100644 --- a/static/widget-test.html +++ b/static/widget-test.html @@ -83,7 +83,7 @@

Current Page:

-

Storage Key: widget_invite_dismissed_

+

Storage Key: widget_invite_dismissed_<slug>_ (Task 10: key 按租户 slug 命名空间化)

@@ -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__)——按前缀清扫全部 + 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-),按前缀清扫 + 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 = "
…rebuilding…
"; - 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 }); } diff --git a/static/widget.js b/static/widget.js index 712d7ad..d95df6e 100644 --- a/static/widget.js +++ b/static/widget.js @@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 + ? 'WhatsApp' + : ""; + var emBtnHtml = email + ? '' + : ""; + var privacyLineHtml = privacyUrl + ? 'By chatting, you agree to our privacy policy.' + : ""; + var chatFooterHtml = privacyLineHtml + ? '' + : ""; + var invitePrivacyHtml = privacyLineHtml + ? '
' + privacyLineHtml + '
' + : ""; + var topicsBtnsHtml = topics.map(function (t) { + return ''; + }).join(""); + var inviteTopicsHtml = topics.map(function (t) { + return ''; + }).join(""); + var popularTopicsSectionHtml = topics.length + ? '
' + : ""; + var welcomeMsgHtml = welcome + ? '
' + esc(welcome) + '
' + : ""; + var panel = document.createElement("div"); panel.className = "chat-panel"; panel.innerHTML = [ '
', '
🤖
', - '

' + title + '

Online now · Usually replies in minutes
', + '

' + esc(title) + '

Online now · Usually replies in minutes
', '
', '', // '', '
', '
Chat ended. We\'ll be in touch!
', '
', - '
How can we help?
Ask about pipe specifications, quotations, MOQ, shipping and delivery.
', + '
How can we help?
Ask about pipe specifications, quotations, MOQ, shipping and delivery.
' + waBtnHtml + emBtnHtml + '
', '
', '
', '
', - '
What kind of piping project are you working on?
', + welcomeMsgHtml, '
', '
', - '', - '
', + popularTopicsSectionHtml, '
', - '', + '', '', '
', - '', + chatFooterHtml, ].join(""); // Invite card HTML template @@ -342,23 +425,23 @@ '', - '', - '', - 'WhatsApp', - '', - '', - '', - '', - '
', - '
', - '', - '', - '', + (whatsapp + ? '' + + '' + + 'WhatsApp' + : ""), + (email + ? '' + : ""), '
', '
', + (topics.length + ? '
' + + '
' + inviteTopicsHtml + '
' + + '
' + : ""), '
', '
', '
', @@ -367,7 +450,7 @@ '', '', '
', - '
By chatting, you agree to our privacy policy.
', + invitePrivacyHtml, '
', '' ].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 - if (document.body) { - init(); - } else { - document.addEventListener("DOMContentLoaded", init); + // Wait for body if script is in ; Task 10: 同时等 brand 落定(成功或 + // 失败都继续)再挂载——两条件齐备才 init,任一失败路径都不白屏 + function domReady(fn) { + if (document.body) fn(); + else document.addEventListener("DOMContentLoaded", fn); } + fetchBrand().then(function (b) { + __brand = b; + domReady(boot); + }); })(); diff --git a/tests/test_brand_endpoint.py b/tests/test_brand_endpoint.py new file mode 100644 index 0000000..2b9173d --- /dev/null +++ b/tests/test_brand_endpoint.py @@ -0,0 +1,46 @@ +"""Task 10: /api/public/brand + /api/public/avatar——品牌白名单下发。 + +红线: +- 品牌端点**显式字段白名单**序列化(BRAND_FIELDS)——绝不序列化整个 + config(api_key/smtp 等内部字段永不出现在响应); +- 响应头 Access-Control-Allow-Origin: *(挂件嵌在客户站,跨源加载); +- ?site= 站点覆盖(空域名站点如 es 靠嵌入代码 data-site 接入), + 未知 site 回退租户默认门面; +- 头像按租户解析 seed_profiles//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 diff --git a/tests/test_invite_static.py b/tests/test_invite_static.py index ff94192..8a84f8f 100644 --- a/tests/test_invite_static.py +++ b/tests/test_invite_static.py @@ -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(): diff --git a/tests/test_widget_namespacing.py b/tests/test_widget_namespacing.py new file mode 100644 index 0000000..a70e0d1 --- /dev/null +++ b/tests/test_widget_namespacing.py @@ -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)