场景集成方式

更新时间:
复制 MD 格式

将外呼机器人的配置场景通过Iframe方式集成到用户自己的系统上使用。

参考阿里云免登方案。

一、前置条件

创建RAM子用户及授权,如子账号已存在,可跳过该步骤

1、RAM 用户

1.1、添加授权

调用 AssumeRole 的身份,需具备 AliyunSTSAssumeRoleAccess 权限

image

image

1.2、凭据配置(AK/SK)

创建AccessKey,然后通过环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET 提供给后端服务。

image

2、创建RAM角色

2.1、创建RAM角色,或者通过RAM的API CreateRole创建RAM角色。

image

image

image

2.2、为刚刚创建的角色授权,AliyunOutboundbotFullAccess

用于访问智能外呼产品,按需授予 AliyunOutboundBotFullAccess

image

image

2.3、角色 ARN 参数获取,注意是角色不是用户的

image

二、Java代码示例

1、Maven 依赖:

<dependencies>
  <dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>tea-openapi</artifactId>
    <version>0.3.9</version>
  </dependency>
  <dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>tea-util</artifactId>
    <version>0.2.23</version>
  </dependency>
  <dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>openapiutil</artifactId>
    <version>0.2.2</version>
  </dependency>
  <dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>credentials-java</artifactId>
    <version>1.0.2</version>
  </dependency>
  <dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.10.1</version>
  </dependency>
</dependencies>

2、Code Sample

流程:AssumeRole 拿临时凭据 → GetSigninToken 换登录令牌 → 把令牌返回给前端。

服务端到此为止,不再关心要嵌入哪个页面。Destination 与最终免登链接由前端拼装,见四、前端集成(HTML+JS)。

重要

配置环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID / ALIBABA_CLOUD_ACCESS_KEY_SECRET

package com.aliyun.sample;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;

public class IframeLoginSample {

    /** STS 服务接入地址,参考 https://api.aliyun.com/product/Sts */
    private static final String STS_ENDPOINT = "sts.cn-hangzhou.aliyuncs.com";
    /** 阿里云联合登录(免登录)网关,服务端只用它换取 SigninToken */
    private static final String SIGN_IN_URL = "https://signin.aliyun.com/federation";

    // 改为你自己的 roleArn,格式 acs:ram::<主账号ID>:role/<角色名>
    private static final String ROLE_ARN = "acs:ram::xxxxxxxxxxxxxxxx:role/aliyunoutboundbot4serviceaccessingrole";

    private static final Gson GSON = new Gson();

    private static class Credentials {
        private String accessKeyId;
        private String accessKeySecret;
        private String securityToken;
    }

    /**
     * <b>description</b> :
     * <p>使用凭据初始化账号Client</p>
     *
     * @return Client
     *
     * @throws Exception
     */
    public static com.aliyun.teaopenapi.Client createClient() throws Exception {
        // 运行前需配置环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID 和 ALIBABA_CLOUD_ACCESS_KEY_SECRET,凭据链会自动读取。
        // 该 AK 所属的 RAM 用户需具备 AliyunSTSAssumeRoleAccess 权限。
        // 工程代码建议使用更安全的无AK方式,凭据配置方式请参见:https://help.aliyun.com/document_detail/378657.html。
        com.aliyun.credentials.Client credential = new com.aliyun.credentials.Client();
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
                .setCredential(credential);
        // Endpoint 请参考 https://api.aliyun.com/product/Sts
        config.endpoint = STS_ENDPOINT;
        return new com.aliyun.teaopenapi.Client(config);
    }

    /**
     * <b>description</b> :
     * <p>API 相关</p>
     *
     * @return OpenApi.Params
     */
    public static com.aliyun.teaopenapi.models.Params createApiInfo() throws Exception {
        com.aliyun.teaopenapi.models.Params params = new com.aliyun.teaopenapi.models.Params()
                // 接口名称
                .setAction("AssumeRole")
                // 接口版本
                .setVersion("2015-04-01")
                // 接口协议
                .setProtocol("HTTPS")
                // 接口 HTTP 方法
                .setMethod("POST")
                .setAuthType("AK")
                .setStyle("RPC")
                // 接口 PATH
                .setPathname("/")
                // 接口请求体内容格式
                .setReqBodyType("json")
                // 接口响应体内容格式
                .setBodyType("json");
        return params;
    }

    /**
     * <b>description</b> :
     * <p>通过 AssumeRole 获取临时 AK / SK / SecurityToken</p>
     * <p>参考 https://help.aliyun.com/document_detail/28763.html</p>
     */
    @SuppressWarnings("unchecked")
    private static Credentials assumeRole(com.aliyun.teaopenapi.Client client) throws Exception {
        com.aliyun.teaopenapi.models.Params params = createApiInfo();
        // runtime options
        com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions();

        Map<String, Object> query = new LinkedHashMap<>();
        query.put("RoleArn", ROLE_ARN);
        // 用于区分不同令牌,可用于访问审计。格式:^[a-zA-Z0-9\.@\-_]+$
        query.put("RoleSessionName", "outboundbot-iframe-" + System.currentTimeMillis());
        // 过期时间,单位秒,取值范围 900 ~ 3600
        query.put("DurationSeconds", 3600);

        com.aliyun.teaopenapi.models.OpenApiRequest request = new com.aliyun.teaopenapi.models.OpenApiRequest()
                .setQuery(com.aliyun.openapiutil.Client.query(query));

        // 返回值实际为 Map 类型,可从 Map 中获得三类数据:响应体 body、响应头 headers、HTTP 返回的状态码 statusCode。
        Map<String, ?> resp = client.callApi(params, request, runtime);
        Map<String, ?> body = (Map<String, ?>) resp.get("body");
        Map<String, ?> raw = body == null ? null : (Map<String, ?>) body.get("Credentials");
        if (raw == null) {
            throw new IllegalStateException("AssumeRole 响应缺少 Credentials");
        }

        Credentials credentials = new Credentials();
        credentials.accessKeyId = (String) raw.get("AccessKeyId");
        credentials.accessKeySecret = (String) raw.get("AccessKeySecret");
        credentials.securityToken = (String) raw.get("SecurityToken");
        return credentials;
    }

    /**
     * <b>description</b> :
     * <p>使用安全令牌获取登录令牌</p>
     * <p>参考 https://help.aliyun.com/document_detail/91913.html</p>
     * <p>TicketType 固定为 mini:默认票 login_aliyunid_ticket 是 SameSite=Lax,
     * 跨站 iframe 不会携带;mini_login_aliyunid_ticket 是 SameSite=None,故嵌入场景必须用 mini 票。</p>
     */
    private static String getSigninToken(Credentials credentials) throws Exception {
        Map<String, String> query = new LinkedHashMap<>();
        query.put("Action", "GetSigninToken");
        query.put("AccessKeyId", credentials.accessKeyId);
        query.put("AccessKeySecret", credentials.accessKeySecret);
        query.put("SecurityToken", credentials.securityToken);
        query.put("TicketType", "mini");

        HttpURLConnection connection = (HttpURLConnection) URI
                .create(SIGN_IN_URL + "?" + encodeQuery(query))
                .toURL()
                .openConnection();
        connection.setRequestMethod("GET");
        connection.setConnectTimeout(10000);
        connection.setReadTimeout(10000);
        connection.setInstanceFollowRedirects(false);
        connection.setRequestProperty("Cache-Control", "no-store");

        int statusCode = connection.getResponseCode();
        if (statusCode != 200) {
            connection.disconnect();
            throw new IllegalStateException("GetSigninToken 请求失败(HTTP " + statusCode + ")");
        }

        String payload = readAll(connection.getInputStream());
        connection.disconnect();

        Map<String, String> result = GSON.fromJson(payload, new TypeToken<Map<String, String>>() {
        }.getType());
        String signinToken = result == null ? null : result.get("SigninToken");
        if (signinToken == null || signinToken.isEmpty()) {
            throw new IllegalStateException("GetSigninToken 响应缺少 SigninToken");
        }
        return signinToken;
    }

    private static String encodeQuery(Map<String, String> query) throws Exception {
        StringBuilder builder = new StringBuilder();
        for (Map.Entry<String, String> entry : query.entrySet()) {
            if (builder.length() > 0) {
                builder.append('&');
            }
            builder.append(URLEncoder.encode(entry.getKey(), "UTF-8"))
                    .append('=')
                    .append(URLEncoder.encode(entry.getValue(), "UTF-8"));
        }
        return builder.toString();
    }

    private static String readAll(InputStream stream) throws Exception {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
            StringBuilder builder = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                builder.append(line);
            }
            return builder.toString();
        }
    }

    public static void main(String[] args) throws Exception {
        com.aliyun.teaopenapi.Client client = createClient();

        // Step 1 通过 AssumeRole 获取临时 AK / SK / SecurityToken
        Credentials credentials = assumeRole(client);

        // Step 2 用临时凭据换取 SigninToken
        String signinToken = getSigninToken(credentials);

        // Step 3 输出 SigninToken。Destination 与免登链接由前端拼装,见文档第 4 节。
        // 实际集成时把上面三步包进你的 Web 接口,返回同样的 JSON,并且该接口必须鉴权。
        // SigninToken 等同登录凭据:不要写入日志、不要提交到代码库。
        Map<String, String> result = new LinkedHashMap<>();
        result.put("signinToken", signinToken);
        System.out.println(GSON.toJson(result));
    }
}

三、页面路径对照表

基准地址:https://outboundbot4service.console.aliyun.com/#

页面

路径

大模型场景列表(提示词模式)

/{instanceId}/outboundbot_prompt_script/text?instanceId=X

大模型场景详情(提示词模式)

/{instanceId}/outboundbot_prompt_script/text/prompt_script_detail?instanceId=X&scriptId=Y

大模型场景列表(工作流程编排)

/{instanceId}/outboundbot_prompt_script/workflow?instanceId=X&tab=flow

大模型场景详情(工作流程编排)

/{instanceId}/outboundbot_prompt_script/workflow/prompt_script_detail?instanceId=X&scriptId=Y

大模型场景列表(函数计算模式)

/{instanceId}/outboundbot_prompt_script/function?instanceId=X

大模型场景详情(函数计算模式)

/{instanceId}/outboundbot_prompt_script/function/prompt_script_detail?instanceId=X&scriptId=Y

大模型声音克隆

/{instanceId}/outboundbot_prompt_script/llm_voice_clone?instanceId=X

大模型全局配置

/{instanceId}/outboundbot_prompt_script/global_variable?instanceId=X

大模型问答管理

/{instanceId}/outboundbot_prompt_script/llm_faq?instanceId=X

知识管理

/{instanceId}/outboundbot_prompt_script/llm_doc?instanceId=X

小模型场景管理

/{instanceId}/outboundbot_script?instanceId=X

小模型场景详情

/{instanceId}/outboundbot_script/ds_scriptsdetail?instanceId=X&scriptId=Y

对话工厂权限

/{instanceId}/outboundbot_script/ds_permission?instanceId=X

四、前端集成(HTML+JS)

服务端只返回 SigninToken,Destination 和最终免登链接由前端拼装。这样要嵌哪个页面、用哪个实例和场景,

都由前端按需决定,后端不必为每个页面单独出接口。

iframe 属性要求

外呼涉及语音交互,嵌入时必须开放以下两个属性:

allow="microphone *"  allowfullscreen

完整示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>智能外呼控制台嵌入示例</title>
  <style>
    html, body { margin: 0; height: 100%; }
    #console-container { height: 100vh; }
    #console-container iframe { display: block; width: 100%; height: 100%; border: 0; }
  </style>
</head>
<body>
  <div id="console-container"></div>

  <script>
    (function () {
      // ===== 配置区 =====
      var SIGN_IN_URL = 'https://signin.aliyun.com/federation';
      var CONSOLE_URL = 'https://outboundbot4service.console.aliyun.com/';

      // 后端接口获取 signinToken 由第二节 Java 逻辑提供服务。
      var TOKEN_API = '/api/outboundbot-signin-token';

      // 改为你自己的业务空间(实例)ID
      var INSTANCE_ID = '请替换为实例ID';
      // 改为你自己的场景 ID,仅「场景详情」类页面需要
      var SCRIPT_ID = '请替换为场景ID';

      // 只接受外呼控制台域名发来的消息
      var ALLOWED_ORIGINS = ['https://outboundbot4service.console.aliyun.com'];
      // 防抖窗口:避免控制台连续上报失效导致无限重载
      var RELOAD_COOLDOWN = 3000;

      // 页面路径清单,均为「不带实例前缀」的基准路径,详见第 3 节
      var PAGES = {
        promptText: '/outboundbot_prompt_script/text',                        // 大模型场景列表(提示词模式)
        promptWorkflow: '/outboundbot_prompt_script/workflow',                // 工作流程编排(含流程管理,用 tab 区分)
        promptFunction: '/outboundbot_prompt_script/function',                // 函数计算模式
        globalVariable: '/outboundbot_prompt_script/global_variable',         // 全局配置
        voiceClone: '/outboundbot_prompt_script/llm_voice_clone',             // 声音克隆
        llmFaq: '/outboundbot_prompt_script/llm_faq',                         // 问答管理
        llmDoc: '/outboundbot_prompt_script/llm_doc',                         // 知识管理
        promptDetail: '/outboundbot_prompt_script/text/prompt_script_detail', // 大模型场景详情,text 可换成 workflow / function
        script: '/outboundbot_script',                                        // 小模型场景管理
        dsScriptDetail: '/outboundbot_script/ds_scriptsdetail'                // 小模型场景详情
      };

      // 要嵌入的页面,按需替换
      var PAGE_PATH = PAGES.promptDetail;

      // ===== 链接拼装 =====
      // 业务空间路由统一以 /:instanceId 为前缀;
      // 漏拼前缀会触发一次补全重定向,还可能落到错误的业务空间
      function createDestination(pagePath) {
        var query = new URLSearchParams({ instanceId: INSTANCE_ID });
        // 仅「场景详情」类页面需要 scriptId
        if (/\/(prompt_script_detail|ds_scriptsdetail)$/.test(pagePath)) {
          query.set('scriptId', SCRIPT_ID);
        }
        // 工作流程编排页面包含流程管理,用 tab 区分
        if (pagePath === PAGES.promptWorkflow) {
          query.set('tab', 'flow');
        }
        return CONSOLE_URL + '#/' + INSTANCE_ID + pagePath + '?' + query.toString();
      }

      function createLoginUrl(signinToken, destination) {
        var query = new URLSearchParams({
          Action: 'Login',
          // 登录失效后的跳转地址,一般配置为自建服务上会 302 回本页的 URL
          LoginUrl: 'https://signin.aliyun.com/login.htm',
          Destination: destination,
          SigninToken: signinToken
        });
        return SIGN_IN_URL + '?' + query.toString();
      }

      // ===== 加载与刷新 =====
      var container = document.getElementById('console-container');
      var frame = null;
      var lastLoadAt = 0;

      function fetchSigninToken() {
        return fetch(TOKEN_API, { cache: 'no-store', credentials: 'same-origin' })
          .then(function (response) {
            if (!response.ok) {
              throw new Error('获取 SigninToken 失败(HTTP ' + response.status + ')');
            }
            return response.json();
          })
          .then(function (result) {
            if (!result || !result.signinToken) {
              throw new Error('响应缺少 signinToken');
            }
            return result.signinToken;
          });
      }

      function mountFrame(src) {
        if (frame && frame.parentNode) {
          frame.parentNode.removeChild(frame);
        }
        frame = document.createElement('iframe');
        frame.src = src;
        frame.title = '智能外呼控制台';
        frame.setAttribute('allow', 'microphone *');
        frame.setAttribute('allowfullscreen', '');
        // 避免带 SigninToken 的 URL 通过 Referer 泄漏给第三方
        frame.setAttribute('referrerpolicy', 'no-referrer');
        container.appendChild(frame);
      }

      function loadConsole() {
        var now = Date.now();
        if (now - lastLoadAt < RELOAD_COOLDOWN) {
          return;
        }
        lastLoadAt = now;

        fetchSigninToken()
          .then(function (signinToken) {
            mountFrame(createLoginUrl(signinToken, createDestination(PAGE_PATH)));
          })
          .catch(function (error) {
            // 不要把 error.message 直接展示给终端用户,其中可能含服务端细节
            console.error('[outboundbot] 免登加载失败', error);
          });
      }

      function handleMessage(event) {
        // 双重校验:来源域名 + 来源窗口。
        // 只校验 origin 时,同域名下的其它 iframe 或被劫持的页面也能伪造消息。
        if (ALLOWED_ORIGINS.indexOf(event.origin) === -1) {
          return;
        }
        if (!frame || event.source !== frame.contentWindow) {
          return;
        }
        // 登录态失效:{ code: 'ConsoleNeedLogin', message: '登录超时,请重新登录' }
        if (event.data && event.data.code === 'ConsoleNeedLogin') {
          loadConsole();
        }
      }

      window.addEventListener('message', handleMessage);
      loadConsole();
    })();
  </script>
</body>
</html>

父页面与 iframe 都必须走 https。写成 http://signin.aliyun.com/... 在 https 页面下会被浏览器按混合内容拦截。

postMessage 协议

外呼控制台在请求接口发现登录 cookie 失效时,会向父页面发送:

{ "code": "ConsoleNeedLogin", "message": "登录超时,请重新登录" }

父页面收到后自行决定刷新 iframe 或跳转登录页。刷新时前端会重新请求 TOKEN_API 换取新令牌,

因此不要把首次拿到的免登链接缓存下来复用——SigninToken 有时效,复用会直接失败。