From bbc7bc520e334862ef2742f4ddde437461da7928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 12 Oct 2026 22:26:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20brand=20=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E4=B8=8B=E5=8F=91(=E7=AB=99=E7=82=B9=E8=A6=86=E7=9B=96/?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E5=A4=B4=E5=83=8F/CORS)=20+=20widget/chat=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=20key=20=E7=A7=9F=E6=88=B7=E5=8C=96?= =?UTF-8?q?=20+=20=E4=BC=9A=E8=AF=9D=E7=AB=99=E7=82=B9=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- app/engine/routes/chat.py | 6 + app/engine/routes/chat_rest.py | 6 + app/engine/routes/public.py | 84 ++++++++++ app/main.py | 2 + seed_profiles/auto/assets/kefu-small.png | Bin 0 -> 30204 bytes seed_profiles/auto/tenant.json | 4 +- seed_profiles/jiancai/assets/kefu-small.png | Bin 0 -> 20288 bytes seed_profiles/jiancai/tenant.json | 4 +- static/chat.html | 24 ++- static/js/chat.js | 128 +++++++++++--- static/widget-test.html | 41 ++++- static/widget.js | 176 +++++++++++++++----- tests/test_brand_endpoint.py | 46 +++++ tests/test_invite_static.py | 15 +- tests/test_widget_namespacing.py | 127 ++++++++++++++ 15 files changed, 570 insertions(+), 93 deletions(-) create mode 100644 app/engine/routes/public.py create mode 100644 seed_profiles/auto/assets/kefu-small.png create mode 100644 seed_profiles/jiancai/assets/kefu-small.png create mode 100644 tests/test_brand_endpoint.py create mode 100644 tests/test_widget_namespacing.py 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 0000000000000000000000000000000000000000..2f234e39a4c919fce9e97d191b04856d0257a999 GIT binary patch literal 30204 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..11cba749bc5eeaa66b2f2877e725994ebefa7888 GIT binary patch literal 20288 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 X6(T1O#cdT?00000NkvXXu0mjf7PF-X literal 0 HcmV?d00001 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 - + 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)
-
🤖
+
🤖
-

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