Skip to content

嵌入第三方web系统 ​

本章完成应用的对外嵌入:把应用以整页 iframe、移动端 iframe 或浮窗脚本三种形式挂到别人的网站上。三种形式共用同一套公开访问链接与访客身份(见独立对话页),区别只在「页面容器由谁提供、入口按钮由谁渲染」。前端沿用官方界面,浮窗脚本接口、来源白名单与每个客户端的当日提问次数由后端补齐。

一、三种嵌入方式 ​

应用概览里点嵌入第三方,会给出三段可直接粘贴的代码:

方式生成的代码页面布局适用场景
整页模式<iframe> 指向 /ui/#/chat/{令牌}桌面布局,左侧有历史记录栏把整个对话页铺满第三方站点的一个页面或容器
移动端模式<iframe> 指向 /ui/#/chat/{令牌}?mode=mobile移动布局,只有对话区与悬浮的历史按钮页面本身就是窄屏,或要嵌进手机端页面
浮窗模式<script> 指向 /api/application/embed右下角圆形入口,点开是 450×600 的对话窗口不想占用页面位置,让访客随时唤起小助手

嵌入第三方弹窗

两种 iframe 方式不需要后端额外提供接口:地址就是公开分享地址,套一层 iframe 而已,页面里的访客认证、会话隔离、对话语言全部沿用公开链接那条链路。浮窗方式多一个脚本接口,脚本在第三方页面里渲染入口按钮并自己创建 iframe。

二、嵌入入口与前提 ​

嵌入入口与公开访问链接共用同一个令牌,链接没启用时按钮不可点:

ts
import { ref } from 'vue'

const accessToken = ref<any>({})
const EmbedDialogRef = ref()

function openDialog() {
  // 令牌来自公开访问链接,未启用时按钮处于禁用态
  EmbedDialogRef.value.open(accessToken.value?.access_token)
}

弹窗里的三段代码由同一个方法拼出来,站点地址取当前地址,不写死主机名;应用编排里声明为接口入参的变量会作为查询串附在代码后面:

ts
import { ref, computed } from 'vue'
import useStore from '@/stores'

const { application } = useStore()
const source1 = ref('')
const source2 = ref('')
const source3 = ref('')

const props = defineProps({
  data: Object,
  apiInputParams: String
})

// apiInputParams 由应用编排基础节点的接口入参拼成,例如 asker=zhangsan
const urlParams1 = computed(() => (props.apiInputParams ? '?' + props.apiInputParams : ''))
const urlParams2 = computed(() => (props.apiInputParams ? '&' + props.apiInputParams : ''))
const urlParams3 = computed(() =>
  props.apiInputParams ? '?mode=mobile&' + props.apiInputParams : '?mode=mobile'
)

const open = (val: string) => {
  source1.value = `<iframe
src="${application.location + val + urlParams1.value}"
style="width: 100%; height: 100%;"
frameborder="0"
allow="microphone">
</iframe>
`

  source2.value = `<script
async
defer
src="${window.location.origin}/api/application/embed?protocol=${window.location.protocol.replace(
    ':',
    ''
  )}&host=${window.location.host}&token=${val}${urlParams2.value}">
<\/script>
`

  source3.value = `<iframe
src="${application.location + val + urlParams3.value}"
style="width: 100%; height: 100%;"
frameborder="0"
allow="microphone">
</iframe>
`
}

application.location 是「当前站点地址 + /ui/#/chat/」,也就是说这三段代码里的主机名来自管理界面当前所在的地址:用域名打开后台,生成的就是域名;换端口后重新复制一次即可。

三、浮窗脚本接口 ​

3.1 接口约定 ​

项值
方法与路径GET /api/application/embed
查询参数protocol:http 或 https;host:站点主机(可带端口);token:公开访问链接的令牌;其余为对话页入参
响应类型application/javascript; charset=utf-8
鉴权匿名可访问,第三方页面不会带登录令牌

3.2 控制器 ​

控制器只做参数绑定,把注入配置交给服务层拼装:

java
package nexus.io.mosskb.controller;

import com.alibaba.fastjson2.JSONObject;

import nexus.io.annotation.Get;
import nexus.io.annotation.Post;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.kb.MossKbApplicationEmbedService;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.http.server.util.Resps;
import nexus.io.tio.utils.hutool.StrUtil;

@RequestPath("/api/application")
public class ApiApplicationController {

  /**
   * 浮窗嵌入:返回一段脚本,第三方页面引入后在右下角挂出对话入口。
   * 脚本按站点地址拼出对话页地址,并带上编排声明的接口入参。
   */
  @Get("/embed")
  public HttpResponse embed(HttpRequest request) {
    String protocol = request.getParam("protocol");
    String host = request.getParam("host");
    Long token = longParam(request, "token");
    return Resps.js(request, Aop.get(MossKbApplicationEmbedService.class).script(protocol, host, token, request.getParam()));
  }

  /** 查询串里的整型参数:缺省或不是数字时返回 null,由调用方给出默认行为。 */
  private Long longParam(HttpRequest request, String name) {
    String value = request.getParam(name);
    if (StrUtil.isBlank(value)) {
      return null;
    }
    try {
      return Long.valueOf(value.trim());
    } catch (NumberFormatException e) {
      return null;
    }
  }
}

Resps.js 直接给出 application/javascript 的响应头,浏览器才会把正文当脚本执行,而不是当纯文本显示。

3.3 出参校验 ​

protocol、host 与 token 会被拼进对话页地址和图标地址,因此先校验再使用:协议只接受 http / https,主机名只接受字母、数字、点、短横线、下划线与端口,令牌必须是数字,并且能在公开访问链接表里查到且处于启用状态。任一条不满足时同样返回脚本,只是配置里的 isAuth 为 false:

java
package nexus.io.mosskb.service.kb;

import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

import com.alibaba.fastjson2.JSONObject;

import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.tio.utils.hutool.StrUtil;

public class MossKbApplicationEmbedService {

  /** 允许的协议:只有 http 与 https 能拼进脚本里的地址。 */
  private static final List<String> SUPPORTED_PROTOCOLS = List.of("http", "https");

  /** 主机名(含可选端口)。取值来自查询串,限制字符集后拼进地址,避免注入。 */
  private static final Pattern HOST = Pattern.compile("[A-Za-z0-9._-]{1,253}(:[0-9]{1,5})?");

  /** 浮窗图标:随前端一起发布的图片,与前端同源。 */
  private static final String FLOAT_ICON_PATH = "/ui/MossKB.gif";

  private static final String FIND_ACCESS_TOKEN = """
      select application_id, is_active, white_active, white_list
      from moss_kb_application_access_token
      where access_token = ?
        and deleted = 0
      """;

  public String script(String protocol, String host, Long token, Map<String, Object> params) {
    String scheme = StrUtil.isBlank(protocol) ? null : protocol.toLowerCase();
    boolean valid = SUPPORTED_PROTOCOLS.contains(scheme) && StrUtil.isNotBlank(host) && HOST.matcher(host).matches()
        && token != null;
    Row accessToken = valid ? Db.findFirst(FIND_ACCESS_TOKEN, token) : null;
    if (accessToken == null || !Boolean.TRUE.equals(accessToken.getBoolean("is_active"))) {
      JSONObject denied = new JSONObject();
      denied.put("isAuth", false);
      return render(denied);
    }

    Long applicationId = accessToken.getLong("application_id");
    JSONObject config = new JSONObject();
    config.put("isAuth", true);
    config.put("protocol", scheme);
    config.put("host", host);
    config.put("token", token.toString());
    config.put("query", apiInputQuery(applicationId, params));
    config.put("floatIcon", scheme + "://" + host + FLOAT_ICON_PATH);
    config.put("whiteActive", Boolean.TRUE.equals(accessToken.getBoolean("white_active")));
    config.put("whiteList", whiteList(accessToken.getStringArray("white_list")));
    return render(config);
  }

  /** 配置以 JSON 注入,脚本正文保持静态,取值不参与脚本拼接。 */
  private String render(JSONObject config) {
    return "window.__MOSSKB_EMBED_CONFIG__ = " + config.toJSONString() + ";\n" + SCRIPT;
  }
}

注入与执行分开带来两个好处:脚本正文是固定字符串,不会被参数改写;配置走 JSON 序列化,引号、中文、换行都由序列化处理,不需要手工转义。实际返回的第一行形如:

javascript
window.__MOSSKB_EMBED_CONFIG__ = {"isAuth":true,"protocol":"http","host":"127.0.0.1:3000","token":"7335647387278176","query":"","floatIcon":"http://127.0.0.1:3000/ui/MossKB.gif","whiteActive":false,"whiteList":[]};

链接被停用、令牌不存在、主机名不合法时返回的是:

javascript
window.__MOSSKB_EMBED_CONFIG__ = {"isAuth":false};

脚本读到 isAuth 为假就在控制台给出一句说明并结束,第三方页面保持原样,不会出现半截入口。

3.4 脚本正文 ​

注入配置之后的脚本正文负责渲染与交互,关键部分如下:

javascript
(function () {
  var config = window.__MOSSKB_EMBED_CONFIG__ || {};
  if (!config.isAuth) {
    console.error('MossKB: 公开访问链接不存在或已停用');
    return;
  }
  if (config.whiteActive && config.whiteList.indexOf(window.location.origin) < 0) {
    console.error('MossKB: 当前页面来源不在白名单内 ' + window.location.origin);
    return;
  }

  var GUIDE_KEY = 'mosskbGuideTip';
  var rootId = 'mosskb-' + Math.random().toString(36).slice(2, 10);
  var chatUrl = config.protocol + '://' + config.host + '/ui/#/chat/' + config.token + '?mode=embed' + config.query;

  function init() {
    var root = document.createElement('div');
    root.id = rootId;
    root.innerHTML = '<div class="mosskb-float"><img src="' + config.floatIcon + '" alt="MossKB"></div>'
        + '<div class="mosskb-window">'
        + '<iframe class="mosskb-frame" src="' + chatUrl + '" allow="microphone"></iframe>'
        + '<div class="mosskb-operate">'
        + '<span class="mosskb-enlarge" title="放大">' + ICON_ENLARGE + '</span>'
        + '<span class="mosskb-restore" title="还原">' + ICON_RESTORE + '</span>'
        + '<span class="mosskb-close" title="关闭">' + ICON_CLOSE + '</span>'
        + '</div></div>';
    var style = document.createElement('style');
    style.appendChild(document.createTextNode(scoped(STYLES)));
    root.appendChild(style);
    document.body.appendChild(root);
    bind(root);
    showGuide(root);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();

脚本的行为可以概括成四条:

  • 入口:右下角一个 64×64 的圆形按钮,图标取站点自身发布的 /ui/MossKB.gif,不需要第三方准备素材;
  • 窗口:点击按钮展开 450×600 的对话窗口,窗口里是 ?mode=embed 的对话页;打开时按钮隐藏,关闭时按钮恢复,两者不会同时出现;
  • 操作:窗口右上角提供放大 / 还原 / 关闭三个按钮,放大后窗口变成半屏宽、满屏高,再点还原回到 450×600;
  • 引导:首次访问在按钮旁给出一条提示,点「我知道了」或关闭后写入 localStorage,同一浏览器不再出现。

样式不是直接写死的选择器,而是按当前页面生成的根元素 id 逐条加前缀:

javascript
function scoped(rules) {
  var text = '';
  for (var i = 0; i < rules.length; i++) {
    text += '#' + rootId + ' ' + rules[i];
  }
  return text;
}

同一个站点嵌入多个应用、甚至同一页面嵌两次时,各自的根 id 不同,样式与元素都不会互相命中,也不会影响第三方页面自己的样式。

浮窗入口与对话窗口

上方第三方的页面保持原样,右下角是脚本挂出的对话窗口:回答、知识来源、消耗 tokens 与耗时都和站内一致。来源里的文档卡片可以直接点开,按分段查看该文档的正文,见分段预览。

四、对话页入参透传 ​

浮窗脚本只把应用自己声明过的接口入参带进对话页:编排基础节点上声明为接口入参的变量,加上提问人标识 asker。其余查询串一律丢弃,避免任意参数被带进对话页:

java
package nexus.io.mosskb.service.kb;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;

import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.hutool.StrUtil;

/** 后续对话要带上的入参:编排里声明为接口入参的变量,以及提问人标识。 */
private static final String ASKER = "asker";

private static final String FIND_WORK_FLOW = """
    select work_flow
    from moss_kb_application
    where id = ?
    """;

private String apiInputQuery(Long applicationId, Map<String, Object> params) {
  if (params == null || params.isEmpty()) {
    return "";
  }
  Set<String> names = new LinkedHashSet<>(apiInputVariables(applicationId));
  names.add(ASKER);
  StringBuilder query = new StringBuilder();
  for (String name : names) {
    append(query, name, params.get(name));
  }
  return query.toString();
}

private void append(StringBuilder query, String name, Object value) {
  if (value == null) {
    return;
  }
  String text = value.toString();
  if (StrUtil.isBlank(text)) {
    return;
  }
  query.append('&').append(name).append('=').append(URLEncoder.encode(text, StandardCharsets.UTF_8));
}

变量名从应用的编排里读出来,兼容基础节点上的两种声明位置:

java
private List<String> apiInputVariables(Long applicationId) {
  List<String> variables = new ArrayList<>();
  String workFlow = Db.queryStr(FIND_WORK_FLOW, applicationId);
  if (StrUtil.isBlank(workFlow)) {
    return variables;
  }
  JSONObject json = null;
  try {
    json = JSON.parseObject(workFlow);
  } catch (RuntimeException e) {
    return variables;
  }
  JSONArray nodes = json == null ? null : json.getJSONArray("nodes");
  if (nodes == null) {
    return variables;
  }
  for (int i = 0; i < nodes.size(); i++) {
    JSONObject node = nodes.getJSONObject(i);
    if (node == null || !"base-node".equals(node.getString("id"))) {
      continue;
    }
    collectInputVariables(node.getJSONObject("properties"), variables);
  }
  return variables;
}

private void collectInputVariables(JSONObject properties, List<String> variables) {
  if (properties == null) {
    return;
  }
  // 声明了 api_input_field_list 时以它为准,否则从输入表单里挑出接口入参
  JSONArray fields = properties.getJSONArray("api_input_field_list");
  boolean declaredByApi = fields != null;
  if (!declaredByApi) {
    fields = properties.getJSONArray("input_field_list");
  }
  if (fields == null) {
    return;
  }
  for (int i = 0; i < fields.size(); i++) {
    JSONObject field = fields.getJSONObject(i);
    if (field == null) {
      continue;
    }
    if (!declaredByApi && !"api_input".equals(field.getString("assignment_method"))) {
      continue;
    }
    String variable = field.getString("variable");
    if (StrUtil.isNotBlank(variable) && !variables.contains(variable)) {
      variables.add(variable);
    }
  }
}

取值统一做 URL 编码后拼到对话页地址上,因此入参里出现空格、中文或 & 都不会截断地址。没有声明任何接口入参、也没有传 asker 时,query 是空串,对话页地址后面不会多出一个问号。

五、来源白名单 ​

白名单用来限定「哪些站点可以嵌入」,在应用概览的访问控制里维护:开关控制是否启用,文本框一行一个来源。

访问控制

配置保存在公开访问链接记录上(white_active、white_list,表结构见独立对话页),脚本读取后按当前页面的来源精确比对:

javascript
if (config.whiteActive && config.whiteList.indexOf(window.location.origin) < 0) {
  console.error('MossKB: 当前页面来源不在白名单内 ' + window.location.origin);
  return;
}

比对的是 window.location.origin(协议 + 主机 + 端口),所以 http://127.0.0.1:4321 与 https://127.0.0.1:4321 是两个来源,端口不同也算不同来源。命中时入口照常出现;不命中时入口不出现,控制台给出上面那句提示。写库时会对条目做去空、去首尾空白与条数限制,最多保留 32 条、每条不超过 128 个字符。

六、每个客户端的当日提问次数 ​

访问控制里的每个客户端提问限制限制同一个访客一天能问多少次,0 表示不限。这里的「客户端」就是公开链接为访客建立的访客身份:换浏览器或清掉访客令牌后重新访问,会登记成新的客户端,次数从零开始。

服务端在提问入口做两件事:先看当日已问次数是否触顶,触顶直接拒绝;提问被受理后把当日次数与累计次数各加一:

java
package nexus.io.mosskb.service.kb;

import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;

public final class ApplicationAccess {

  private static final String FIND_ACCESS_NUM = """
      select access_num
      from moss_kb_application_access_token
      where application_id = ?
        and deleted = 0
      """;

  private static final String FIND_PUBLIC_CLIENT = """
      select intraday_access_num
      from moss_kb_application_public_access_client
      where client_id = ?
        and application_id = ?
        and deleted = 0
      order by id desc
      limit 1
      """;

  /** 跨天时把当日计数归零,只动前一天留下的记录。 */
  private static final String RESET_INTRADAY_ACCESS_NUM = """
      update moss_kb_application_public_access_client
      set intraday_access_num = 0,
          update_time = now()
      where client_id = ?
        and application_id = ?
        and deleted = 0
        and update_time < date_trunc('day', now())
      """;

  private static final String COUNT_QUESTION = """
      update moss_kb_application_public_access_client
      set access_num = access_num + 1,
          intraday_access_num = intraday_access_num + 1,
          update_time = now()
      where client_id = ?
        and application_id = ?
        and deleted = 0
      """;

  /**
   * 访客的当日提问上限:公开访问链接上配置了次数(0 表示不限)时按访客分别累计。
   * 返回 null 表示可以提问,否则返回拒绝原因;应用所有者不受该限制。
   */
  public static String refuseReason(Long clientId, Long applicationId) {
    if (owns(clientId, applicationId)) {
      return null;
    }
    Row client = publicClient(clientId, applicationId);
    if (client == null) {
      return null;
    }
    Integer limit = Db.queryInt(FIND_ACCESS_NUM, applicationId);
    if (limit == null || limit <= 0) {
      return null;
    }
    Integer used = client.getInt("intraday_access_num");
    if (used != null && used >= limit) {
      return "今日提问次数已用完,请明天再试";
    }
    return null;
  }

  /** 记一次访客提问,当日次数与累计次数同时加一。 */
  public static void countQuestion(Long clientId, Long applicationId) {
    if (owns(clientId, applicationId)) {
      return;
    }
    Db.update(COUNT_QUESTION, clientId, applicationId);
  }

  private static Row publicClient(Long clientId, Long applicationId) {
    if (clientId == null || applicationId == null) {
      return null;
    }
    // 取记录之前先把上一天的计数归零,零点任务还没跑到也能按自然日计算
    Db.update(RESET_INTRADAY_ACCESS_NUM, clientId, applicationId);
    return Db.findFirst(FIND_PUBLIC_CLIENT, clientId, applicationId);
  }
}

提问入口在会话归属与应用可用性校验之后接入,拒绝时以普通业务错误返回,前端对话窗口直接展示这句话:

java
Long application_id = record.getLong("application_id");
if (!ApplicationAccess.canChat(caller, application_id)) {
  return ResultVo.fail("应用已停用或无权访问");
}
// 分享页访客按公开访问链接上配置的当日次数限制
String refuseReason = ApplicationAccess.refuseReason(caller, application_id);
if (refuseReason != null) {
  return ResultVo.fail(refuseReason);
}
java
chatWichApplication(channelContext, quesiton, applicationVo, chatId, messageId, mossKbSearchStep, context.messages());
ApplicationAccess.countQuestion(caller, application_id);
started = true;

计数在回答开始生成之后才加,模型调用失败的那一轮不会占用次数。截图是限制设为「每个客户端 1 次/天」时的第二次提问:

超出当日次数

除跨天时的即时归零外,调度器每天零点会把所有访客的当日次数统一置零,让 intraday_access_num 与自然日始终对齐:

java
package nexus.io.mosskb.task;

import org.quartz.JobExecutionContext;

import lombok.extern.slf4j.Slf4j;
import nexus.io.db.activerecord.Db;
import nexus.io.tio.utils.quartz.AbstractJobWithLog;

/** 每天把分享页访客的当日提问次数归零,次数上限按自然日重新计算。 */
@Slf4j
public class ResetClientAccessNumJob extends AbstractJobWithLog {

  private static final String RESET_INTRADAY_ACCESS_NUM = """
      update moss_kb_application_public_access_client
      set intraday_access_num = 0,
          update_time = now()
      where intraday_access_num <> 0
      """;

  @Override
  public void run(JobExecutionContext context) throws Exception {
    int updated = Db.update(RESET_INTRADAY_ACCESS_NUM);
    log.info("reset client access num job finished, updated rows: {}", updated);
  }
}

任务按类名与 cron 注册,与已有的清理任务写在同一份配置里:

properties
nexus.io.mosskb.task.SchduleTaskPerHour = 0 0 */1 * * ?
nexus.io.mosskb.task.CleanChatLogJob = 0 5 0 * * ?
nexus.io.mosskb.task.ResetClientAccessNumJob = 0 0 0 * * ?

七、鉴权与放行 ​

浮窗脚本由第三方页面直接引入,请求里没有登录令牌,也没有访客令牌,因此必须在拦截器里放行;放行范围只有这一个地址,其余嵌入相关的接口仍然走公开链接的放行规则:

java
package nexus.io.mosskb.config;

import nexus.io.mosskb.inteceptor.MossKbAuthInterceptor;
import nexus.io.tio.boot.http.interceptor.HttpInteceptorConfigure;
import nexus.io.tio.boot.http.interceptor.HttpInterceptorModel;
import nexus.io.tio.boot.server.TioBootServer;

public class MossKbInterceptorConfiguration {

  public void config() {
    MossKbAuthInterceptor authTokenInterceptor = new MossKbAuthInterceptor();
    HttpInterceptorModel model = new HttpInterceptorModel();
    model.setInterceptor(authTokenInterceptor);
    model.addBlockUrl("/**");

    model.addAllowUrls("/api/profile", "/api/application/authentication");

    // 浮窗脚本由第三方页面直接引入,没有本地登录令牌
    model.addAllowUrls("/api/application/embed");
  }
}

脚本本身不带数据,放开不会泄露内容:真正的问答请求发生在脚本创建的 iframe 里,iframe 的地址就是本站点,访客令牌在浏览器里由公开链接的认证接口签发,接口调用仍然受公开路径白名单与会话归属校验约束。

八、界面操作步骤 ​

  1. 打开应用,进入应用概览,确认公开访问链接已开启,复制链接或令牌;
  2. 点嵌入第三方,按需要的形态复制代码;浮窗方式复制下面那段 <script>;
  3. 把代码粘到第三方站点的页面里(整页与移动端粘 <iframe>,浮窗粘 <script>);
  4. 需要限定来源时点访问控制,打开白名单开关,一行一个来源,保存后刷新第三方页面即可生效;
  5. 需要限制每个访客每天的提问次数时,在同一个弹窗里填写次数,0 表示不限;
  6. 回到第三方页面刷新:整页与移动端方式会直接铺开对话页,浮窗方式右下角出现圆形入口。

浮窗方式落到第三方页面里就是普通的一个 script 标签,页面其余部分不用改:

html
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>产品帮助中心</title>
  </head>
  <body>
    <h1>产品帮助中心</h1>
    <p>正文保持原样,右下角会出现对话入口。</p>

    <!-- 浮窗方式:脚本自己渲染入口与对话窗口 -->
    <script
      async
      defer
      src="http://127.0.0.1:3000/api/application/embed?protocol=http&host=127.0.0.1:3000&token=7335647387278176"
    ></script>
  </body>
</html>

整页与移动端方式则是给容器里放一个 iframe,容器有多大对话页就铺多大:

html
<!-- 整页方式:容器铺满页面 -->
<iframe
  src="http://127.0.0.1:3000/ui/#/chat/7335647387278176"
  style="width: 100%; height: 100%"
  frameborder="0"
  allow="microphone">
</iframe>

<!-- 移动端方式:多一个 mode 参数 -->
<iframe
  src="http://127.0.0.1:3000/ui/#/chat/7335647387278176?mode=mobile"
  style="width: 100%; height: 100%"
  frameborder="0"
  allow="microphone">
</iframe>

三段代码里的令牌是公开访问链接的令牌,与后台登录令牌无关;需要换一个令牌时,在应用概览里点公开链接右侧的刷新按钮,旧链接与旧代码同时失效。

整页嵌入

九、验收结果 ​

验证在浏览器里完成,第三方站点为本地启动的示例页面 http://127.0.0.1:4321/,对话服务为 http://127.0.0.1:3000,嵌入的令牌为 7335647387278176。

操作观察结果
请求浮窗脚本接口200,Content-Type 为 application/javascript;charset=utf-8,首行注入配置
令牌不存在 / 链接停用后再取脚本返回 isAuth:false,第三方页面不出现任何入口
host 写成带引号与尖括号的串返回 isAuth:false,地址没有被拼进脚本
第三方页面引入脚本右下角出现圆形入口,首次访问带一条引导提示,关闭后刷新不再出现
点击入口展开 450×600 的对话窗口,iframe 地址为 /ui/#/chat/7335647387278176?mode=embed
在窗口里提问「行政复议的一般申请期限是多久」流式回答引用《中华人民共和国行政复议法》第二十条,给出「六十日内」,知识来源、消耗 tokens 与耗时与站内一致
点放大 / 还原 / 关闭窗口依次为 450×600、832×905(半屏宽满屏高)、450×600,关闭后窗口隐藏、圆形入口恢复
用未声明的参数请求脚本只有编排声明过的变量与 asker 被带进对话页
白名单填写示例站点来源入口正常出现
白名单改成其他来源入口不出现,控制台提示「当前页面来源不在白名单内 http://127.0.0.1:4321」
每个客户端限制 1 次/天,连续提问两次第一次正常回答,第二次提示「今日提问次数已用完,请明天再试」
整页 iframe 嵌入满屏对话页,左侧历史记录、右侧问答均可用
移动端 iframe 嵌入使用移动布局,只保留对话区与历史按钮

十、部署提示 ​

  • 三段代码都按站点当前地址生成,换成域名访问后台后重新复制一次即可;反向代理需要同时转发 /api 与 /ui/。
  • 脚本与对话页同源:脚本地址、host 参数与对话页地址都指向同一个站点,第三方页面只需要放行这一个域,不需要为嵌入单独布点。
  • 管理后台与嵌入站点可以不同域:嵌入用的是公开访问链接,不依赖登录态;管理后台放在内网、对外只暴露 /api/application/embed、/api/application/authentication、/api/application/profile、/ui/ 与公开链接用到的对话接口即可。
  • HTTPS 站点嵌入时整条链路都要 HTTPS:protocol 参数取当前页面的协议,HTTPS 页面里嵌 HTTP 地址会被浏览器拦下;此时反代与证书要一起准备好。
  • 白名单按来源精确比对:写域名时带上协议与端口,预览环境、正式环境各写一条。
  • 访客次数按自然日归零:归零任务与对话服务共用同一套定时配置,服务重启后当天计数保留,跨天由取记录时的归零与零点任务共同保证。

十一、已知边界 ​

  • 白名单在浏览器侧校验:它拦住的是「入口是否出现」,不是网络请求本身;需要更强的准入时,应在反向代理或网关层再加一层来源校验。
  • 浮窗入口使用固定的图标与位置:图标取站点发布的 /ui/MossKB.gif,位置固定在右下角、距边缘 30 像素;窗口尺寸为 450×600,放大后为半屏宽、满屏高。
  • 次数限制按访客身份而不是按人:访客身份保存在浏览器里,清掉访客令牌或换浏览器会重新计数;要按账号计次,应使用应用 API 密钥那条链路。
  • 应用所有者不受次数限制:限制只作用于公开链接的访客,后台登录账号在站内提问不受影响。
  • 嵌入页与分享页共用会话数据:浮窗里产生的会话就是该访客的公开链接会话,会进入对话日志并参与保留天数清理,见对话日志。

MIT Licensed