嵌入第三方web系统
本章完成应用的对外嵌入:把应用以整页 iframe、移动端 iframe 或浮窗脚本三种形式挂到别人的网站上。三种形式共用同一套公开访问链接与访客身份(见独立对话页),区别只在「页面容器由谁提供、入口按钮由谁渲染」。前端沿用官方界面,浮窗脚本接口、来源白名单与每个客户端的当日提问次数由后端补齐。
一、三种嵌入方式
应用概览里点嵌入第三方,会给出三段可直接粘贴的代码:
| 方式 | 生成的代码 | 页面布局 | 适用场景 |
|---|---|---|---|
| 整页模式 | <iframe> 指向 /ui/#/chat/{令牌} | 桌面布局,左侧有历史记录栏 | 把整个对话页铺满第三方站点的一个页面或容器 |
| 移动端模式 | <iframe> 指向 /ui/#/chat/{令牌}?mode=mobile | 移动布局,只有对话区与悬浮的历史按钮 | 页面本身就是窄屏,或要嵌进手机端页面 |
| 浮窗模式 | <script> 指向 /api/application/embed | 右下角圆形入口,点开是 450×600 的对话窗口 | 不想占用页面位置,让访客随时唤起小助手 |

两种 iframe 方式不需要后端额外提供接口:地址就是公开分享地址,套一层 iframe 而已,页面里的访客认证、会话隔离、对话语言全部沿用公开链接那条链路。浮窗方式多一个脚本接口,脚本在第三方页面里渲染入口按钮并自己创建 iframe。
二、嵌入入口与前提
嵌入入口与公开访问链接共用同一个令牌,链接没启用时按钮不可点:
import { ref } from 'vue'
const accessToken = ref<any>({})
const EmbedDialogRef = ref()
function openDialog() {
// 令牌来自公开访问链接,未启用时按钮处于禁用态
EmbedDialogRef.value.open(accessToken.value?.access_token)
}
弹窗里的三段代码由同一个方法拼出来,站点地址取当前地址,不写死主机名;应用编排里声明为接口入参的变量会作为查询串附在代码后面:
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 控制器
控制器只做参数绑定,把注入配置交给服务层拼装:
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:
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 序列化,引号、中文、换行都由序列化处理,不需要手工转义。实际返回的第一行形如:
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":[]};
链接被停用、令牌不存在、主机名不合法时返回的是:
window.__MOSSKB_EMBED_CONFIG__ = {"isAuth":false};
脚本读到 isAuth 为假就在控制台给出一句说明并结束,第三方页面保持原样,不会出现半截入口。
3.4 脚本正文
注入配置之后的脚本正文负责渲染与交互,关键部分如下:
(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 逐条加前缀:
function scoped(rules) {
var text = '';
for (var i = 0; i < rules.length; i++) {
text += '#' + rootId + ' ' + rules[i];
}
return text;
}
同一个站点嵌入多个应用、甚至同一页面嵌两次时,各自的根 id 不同,样式与元素都不会互相命中,也不会影响第三方页面自己的样式。

上方第三方的页面保持原样,右下角是脚本挂出的对话窗口:回答、知识来源、消耗 tokens 与耗时都和站内一致。来源里的文档卡片可以直接点开,按分段查看该文档的正文,见分段预览。
四、对话页入参透传
浮窗脚本只把应用自己声明过的接口入参带进对话页:编排基础节点上声明为接口入参的变量,加上提问人标识 asker。其余查询串一律丢弃,避免任意参数被带进对话页:
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));
}
变量名从应用的编排里读出来,兼容基础节点上的两种声明位置:
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,表结构见独立对话页),脚本读取后按当前页面的来源精确比对:
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 表示不限。这里的「客户端」就是公开链接为访客建立的访客身份:换浏览器或清掉访客令牌后重新访问,会登记成新的客户端,次数从零开始。
服务端在提问入口做两件事:先看当日已问次数是否触顶,触顶直接拒绝;提问被受理后把当日次数与累计次数各加一:
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);
}
}
提问入口在会话归属与应用可用性校验之后接入,拒绝时以普通业务错误返回,前端对话窗口直接展示这句话:
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);
}
chatWichApplication(channelContext, quesiton, applicationVo, chatId, messageId, mossKbSearchStep, context.messages());
ApplicationAccess.countQuestion(caller, application_id);
started = true;
计数在回答开始生成之后才加,模型调用失败的那一轮不会占用次数。截图是限制设为「每个客户端 1 次/天」时的第二次提问:

除跨天时的即时归零外,调度器每天零点会把所有访客的当日次数统一置零,让 intraday_access_num 与自然日始终对齐:
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 注册,与已有的清理任务写在同一份配置里:
nexus.io.mosskb.task.SchduleTaskPerHour = 0 0 */1 * * ?
nexus.io.mosskb.task.CleanChatLogJob = 0 5 0 * * ?
nexus.io.mosskb.task.ResetClientAccessNumJob = 0 0 0 * * ?
七、鉴权与放行
浮窗脚本由第三方页面直接引入,请求里没有登录令牌,也没有访客令牌,因此必须在拦截器里放行;放行范围只有这一个地址,其余嵌入相关的接口仍然走公开链接的放行规则:
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 的地址就是本站点,访客令牌在浏览器里由公开链接的认证接口签发,接口调用仍然受公开路径白名单与会话归属校验约束。
八、界面操作步骤
- 打开应用,进入应用概览,确认公开访问链接已开启,复制链接或令牌;
- 点嵌入第三方,按需要的形态复制代码;浮窗方式复制下面那段
<script>; - 把代码粘到第三方站点的页面里(整页与移动端粘
<iframe>,浮窗粘<script>); - 需要限定来源时点访问控制,打开白名单开关,一行一个来源,保存后刷新第三方页面即可生效;
- 需要限制每个访客每天的提问次数时,在同一个弹窗里填写次数,
0表示不限; - 回到第三方页面刷新:整页与移动端方式会直接铺开对话页,浮窗方式右下角出现圆形入口。
浮窗方式落到第三方页面里就是普通的一个 script 标签,页面其余部分不用改:
<!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,容器有多大对话页就铺多大:
<!-- 整页方式:容器铺满页面 -->
<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 密钥那条链路。
- 应用所有者不受次数限制:限制只作用于公开链接的访客,后台登录账号在站内提问不受影响。
- 嵌入页与分享页共用会话数据:浮窗里产生的会话就是该访客的公开链接会话,会进入对话日志并参与保留天数清理,见对话日志。
