# 示例代码介绍

本教程提供的示例代码基于 [Python 3](https://www.python.org/) 环境和 [Flask](https://dormousehole.readthedocs.io/en/latest/) 框架编写。

## 项目结构

```
.
├── README.zh.md     ----- 说明文档
├── public
│   ├── svg     ----- 前端图形文件
│   ├── index.css     ----- 前端展示样式
│   ├── index.js     ----- 前端交互代码
├── templates
│   ├── index.html     ----- 前端用户信息展示页面
├── auth.py     ----- 服务端获取jsapi_ticket等
├── server.py     ----- 服务端核心业务代码
├── requirements.txt     ----- 环境配置文件
└── .env     ----- 全局默认配置文件，主要存储App ID和App Secret等
```

项目结构说明：

- public 和 templates 节点：前端模块，主要功能是调取客户端 API（JSAPI）获取用户信息、展示用户信息。
- 其他节点：服务端模块，使用 Flask 构建，主要功能如下。

- 使用 App ID 和 App Secret 获取 tenant_access_token；
    - 使用 tenant_access_token 获取 jsapi_ticket；
    - 使用 jsapi_ticket、随机字符串、当前时间戳、当前鉴权的网页 URL 生成签名 signature。更多鉴权信息，可参见[组件 SDK 鉴权流程](https://open.feishu.cn/document/uYjL24iN/uUDO3YjL1gzN24SN4cjN)。

## 代码解析

业务处理的逻辑图如下所示。

![](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/a7a45ab6607d0392767198dd563fb21f_wEg4mBAdVI.png?height=434&lazyload=true&width=1351)

### 服务端代码

1. 获取 access_token。

调用服务端 API 获取应用资源时，需要通过 access_token 来判断调用者身份。企业自建应用可通过[自建应用获取 tenant_access_token](https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal) 接口获取。
access_token 存在有效期，因此开发者需要在自己的服务端及时刷新凭证，以防止过期。access_token 有效期为 2 小时。再次调用`自建应用获取 tenant_access_token`接口时：

- 如果 access_token 剩余有效时间大于半小时，接口返回的 access_token 和旧的 access_token 值相同，且不续期。
- 如果 access_token 剩余有效时间小于半小时，接口会返回一个新的 access_token，与此同时旧的 access_token 依然有效，直到其原定期限过期。

示例代码路径：web_app_with_jssdk/python/auth.py。在 auth.py 文件中，Auth 类的`authorize_tenant_access_token`方法实现了 access_token 的获取。

```Python
def authorize_tenant_access_token(self):
    # 获取tenant_access_token，基于开放平台能力实现，具体参考文档：https://open.feishu.cn/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/tenant_access_token_internal
    url = "{}{}".format(self.feishu_host, TENANT_ACCESS_TOKEN_URI)
    req_body = {"app_id": self.app_id, "app_secret": self.app_secret}
    response = requests.post(url, req_body)
    Auth._check_error_response(response)
    self.tenant_access_token = response.json().get("tenant_access_token")
```

2. 获取 jsapi_ticket。

jsapi_ticket 代表网页应用调用飞书 JSAPI 的临时凭证。你可利用上述获取的 access_token，调用[获取 jsapi_ticket](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/h5_js_sdk/authorization) 接口获得 jsapi_ticket。
- 应用获取的 jsapi_ticket 存在有效期，过期时间为接口返回数据中的 `expire_in` 字段对应的秒数。当你再次调用`获取 jsapi_ticket`接口时：

- 如果 jsapi_ticket 剩余有效时间大于半小时，接口返回的 jsapi_ticket 和旧的 jsapi_ticket 值相同，但旧的 jsapi_ticket 不会续期。
	- 如果 jsapi_ticket 剩余有效时间小于半小时，接口返回一个新的 jsapi_ticket，与此同时旧的 jsapi_ticket 依然有效，到其原定期限过期。

- 由于获取 jsapi_ticket 的 API 调用次数有限，频繁刷新 jsapi_ticket 会导致 API 调用受限，影响自身业务，所以开发者在使用时需要缓存 jsapi_ticket，缓存有效期可根据接口返回的 `expire_in` 字段对应的秒数来设置，并不需要每次都从接口拉取。

- 如果获取 jsapi_ticket 失败时，服务端返回的错误码为 99991401（errorMsg: ip %s is denied by app setting），说明当前 IP 被白名单限制。开启 IP 白名单后，所有接口请求都会检查来源 IP，仅白名单中的来源请求可以正常调用开放平台 API，非白名单中的请求则会被拒绝。
<br>
前往 [开发者后台](https://open.feishu.cn/app) > 应用详情页 > **安全设置** > **IP 白名单** 中可查看是否配置了 IP 白名单，或者自行配置业务所需的 IP 白名单范围。

示例代码路径：web_app_with_jssdk/python/auth.py。在 auth.py 文件中，Auth 类的`get_ticket`方法实现了 jsapi_ticket 的获取。

```Python
def get_ticket(self):
    # 获取jsapi_ticket，具体参考文档：https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/h5_js_sdk/authorization
    self.authorize_tenant_access_token()
    url = "{}{}".format(self.feishu_host, JSAPI_TICKET_URI)
    headers = {
        "Authorization": "Bearer " + self.tenant_access_token,
        "Content-Type": "application/json",
    }
    resp = requests.post(url=url, headers=headers)
    Auth._check_error_response(resp)
    return resp.json().get("data").get("ticket", "")
```

3. 生成签名并返回鉴权参数。

获取 jsapi_ticket 后，生成 JSSDK 权限验证的签名。

**获取签名所需的参数说明**

参数 | 数据类型 | 示例值 | 描述
---|---|---|---
noncestr | string | Y7a8KkqX041bsSwT | 随机字符串。
jsapi_ticket | string | 617bf955832a4d4d80d9d8d85917a427 | 上一步骤获得的 ticket。
timestamp | number | 1510045655000 | 当前时间戳，毫秒级。<br>数据类型不能使用 string 类型。
url | string | https://example.cn/test/1234/content.html | 当前网页的 URL（可以是本地局域网网址），不包含#及其后面部分。这里url建议由前端通过接口传给后端，前端应当使用`encodeURIComponent(location.href.split("#")[0]`获取当前页面url是最准确的，不要手写或者在此基础上拼接参数，以免验签时候url不匹配导致验签失败。

**签名生成规则**

将所有待签名参数按照字段名的 ASCII 码从小到大排序（字典序）后，使用 URL 键值对的格式，即`key1=value1&key2=value2…`拼接成字符串 verifyStr，对拼成的字符串 verifyStr 做 sha1 加密，得到签名 signature。
- 出于安全考虑，开发者必须在服务器端实现签名的逻辑。
- 拼接字符串 verifyStr 的所有参数名均为小写字符。
- 字段名和字段值都采用原始值，不进行 URL 转义。

示例：

- 根据 jsapi_ticket、noncestr、timestamp、url 的顺序拼接成字符串 verifyStr。

```
  jsapi_ticket=617bf955832a4d4d80d9d8d85917a427&noncestr=Y7a8KkqX041bsSwT&timestamp=1510045655000&url=https://example.cn/test/1234/content.html
  ```

- 对 verifyStr 进行 sha1 签名，得到 signature。

```
  40a68999ecf7e05907edba43b31a50fd1830c777
  ```

示例代码路径：web_app_with_jssdk/python/server.py。在 server.py 文件中，`get_config_parameters`方法利用前端传来的、需要进行鉴权的网页 URL，生成签名 signature，并将鉴权所需参数返回给前端。

```Python
# 获取并返回接入方前端将要调用的config接口所需的参数
@app.route("/get_config_parameters", methods=["GET"])
def get_config_parameters():    
    # 接入方前端传来的需要鉴权的网页url
    url = request.args.get("url")
    # 初始化Auth类时获取的jsapi_ticket
    ticket = auth.get_ticket()
    # 当前时间戳，毫秒级
    timestamp = int(time.time()) * 1000
    # 拼接成字符串 
    verify_str = "jsapi_ticket={}&noncestr={}&timestamp={}&url={}".format(
        ticket, NONCE_STR, timestamp, url
    )
    # 对字符串做sha1加密，得到签名signature
    signature = hashlib.sha1(verify_str.encode("utf-8")).hexdigest()
    # 将鉴权所需参数返回给前端
    return jsonify(
        {
            "appid": APP_ID,
            "signature": signature,
            "noncestr": NONCE_STR,
            "timestamp": timestamp,
        }
    )
```

其中你需要注意，在 server.py 文件中，需通过 .env 文件加载环境变量参数。

```Python
# 从 .env 文件加载环境变量参数
load_dotenv(find_dotenv())

...

# 获取环境变量
APP_ID = os.getenv("APP_ID")
APP_SECRET = os.getenv("APP_SECRET")
FEISHU_HOST = os.getenv("FEISHU_HOST")    
```

.env 文件路径：web_app_with_jssdk/python/.env。在启动服务时，需要填写应用真实的 APP_ID、APP_SECRET。

```
APP_ID=cli_9fxxxx00b
APP_SECRET=EX6xxxxOF
FEISHU_HOST=https://open.feishu.cn    # 固定取值。
```

### 前端（客户端）代码

前端利用服务端传来的数据实现 JSAPI 鉴权，鉴权成功后即可进行 JSAPI 的调用。

1. 引入 JSSDK。

JSSDK 为网页应用提供了调用手机系统功能和飞书客户端功能（如：扫一扫、云文档）的能力，并支持性能优化，使你的网页应用体验能够接近原生体验。
- 在需要调用 JSAPI 的页面中，引入 JS 文件，更多信息参见[开发网页应用简介](https://open.feishu.cn/document/uYjL24iN/uMTMuMTMuMTM/introduction)。
- 只有在飞书应用内打开当前网页应用，才会注入全局变量。在其他应用（比如外部浏览器网页）内打开则不会注入。因此，开发者的网页仅可在飞书应用内成功调用 JSAPI。
- 你需要将调用 JSAPI 的页面所在的 `域名:端口号`，配置在 [开发者后台](https://open.feishu.cn/app) > 应用详情页 > 安全设置 > H5 可信
域名中。

示例代码路径：web_app_with_jssdk/python/templates/index.html。在 index.html 文件中，引入 JSSDK。引入后，得到两个全局变量`h5sdk`以及`tt`。目前支持 AMD 或 CMD 引入方式（示例代码为 AMD 引入方式）。

```HTML
<!DOCTYPE html>

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>网页应用鉴权</title>
    <link rel="stylesheet" href="/public/index.css" />
    <script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script>

<script
      type="text/javascript"
      src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.16.js"
    ></script>

<script src="https://unpkg.com/vconsole/dist/vconsole.min.js"></script>
    <script>
      var vConsole = new window.VConsole();
    </script>
  </head>

<body>

<script src="/public/index.js"></script>
  </body>
</html>
```

2. 调用 [config](https://open.feishu.cn/document/uYjL24iN/uQjMuQjMuQjM/authentication/h5sdkconfig) 接口进行 JSAPI 鉴权。

前端可利用服务端传来的 appId、timestamp、nonceStr、signature 字段，调用 config 接口进行 JSAPI 鉴权。如果鉴权校验失败，除了可以在 onFail 中进行失败回调的处理，还可以在 h5sdk.error 接口中进行处理（需要在调用 config 接口前完成）。

示例代码路径：web_app_with_jssdk/python/public/index.js。在 index.js 文件中，`apiAuth` 函数中调用 config 接口实现了 JSAPI 的鉴权。

```js
    function apiAuth() {
      console.log("start apiAuth");
      if (!window.h5sdk) {
        console.log("invalid h5sdk");
        alert("please open in feishu");
        return;
      }

// 调用config接口的当前网页url
      // 这里前端一定要用这种方式获取，不建议手写，以免获取到的链接和真实使用的有差距
      const url = encodeURIComponent(location.href.split("#")[0]);
      console.log("接入方前端将需要鉴权的url发给接入方服务端,url为:", url);
      // 向接入方服务端发起请求，获取鉴权参数（appId、timestamp、nonceStr、signature）
      fetch(`/get_config_parameters?url=${url}`)
        .then((response) =>
          response.json().then((res) => {
            console.log(
              "接入方服务端返回给接入方前端的结果(前端调用config接口的所需参数):", res
            );
            // 通过error接口处理API验证失败后的回调
            window.h5sdk.error((err) => {
              throw ("h5sdk error:", JSON.stringify(err));
            });
            // 调用config接口进行鉴权
            window.h5sdk.config({
              appId: res.appid,
              timestamp: res.timestamp,
              nonceStr: res.noncestr,
              signature: res.signature,
              jsApiList: [],
              //鉴权成功回调
              onSuccess: (res) => {
                console.log(`config success: ${JSON.stringify(res)}`);
              },
              //鉴权失败回调
              onFail: (err) => {
                throw `config failed: ${JSON.stringify(err)}`;
              },
            });
            // 完成鉴权后，便可在 window.h5sdk.ready 里调用 JSAPI
            window.h5sdk.ready(() => {
              // window.h5sdk.ready回调函数在环境准备就绪时触发
              // 调用 getUserInfo API 获取已登录用户的基本信息，详细文档参见https://open.feishu.cn/document/uYjL24iN/ucjMx4yNyEjL3ITM
              tt.getUserInfo({
                // getUserInfo API 调用成功回调
                success(res) {
                  console.log(`getUserInfo success: ${JSON.stringify(res)}`);
                  // 单独定义的函数showUser，用于将用户信息展示在前端页面上
                  showUser(res.userInfo);
                },
                // getUserInfo API 调用失败回调
                fail(err) {
                  console.log(`getUserInfo failed:`, JSON.stringify(err));
                },
              });
              // 调用 showToast API 弹出全局提示框，详细文档参见https://open.feishu.cn/document/uAjLw4CM/uYjL24iN/block/api/showtoast
              tt.showToast({
                title: "鉴权成功",
                icon: "success",
                duration: 3000,
                success(res) {
                  console.log("showToast 调用成功", res.errMsg);
                },
                fail(res) {
                  console.log("showToast 调用失败", res.errMsg);
                },
                complete(res) {
                  console.log("showToast 调用结束", res.errMsg);
                },
              });
            });
          })
        )
        .catch(function (e) {
          console.error(e);
        });
    }
    ```

3. 调用 JSAPI。

完成 JSAPI 鉴权后，即可在`window.h5sdk.ready`里调用 JSAPI。示例代码路径：web_app_with_jssdk/python/public/index.js。在`apiAuth`函数中的`window.h5sdk.ready`函数里实现 JSAPI 的调用。
- 出于数据安全考虑，应用需要申请资源访问的权限，并经过开放平台或租户管理员审核后，才可使用对应的开放能力。
<br>
如果你调用的 JSAPI 响应体中某个字段，在对应的开发文档中标注了 **字段权限要求**，表明此字段为敏感字段，仅当应用开通了对应的权限后才会在接口的响应体中返回此字段。如果无需获取这些字段，则不建议申请。权限申请的操作步骤，详情参见[申请 API 权限](https://open.feishu.cn/document/ukTMukTMukTM/uQjN3QjL0YzN04CN2cDN)。

- JSAPI 的调用需要保证在`window.h5sdk.ready`回调函数触发后调用，否则无效。

```js
// 完成鉴权后，便可在 window.h5sdk.ready 里调用 JSAPI
window.h5sdk.ready(() => {
  // window.h5sdk.ready回调函数在环境准备就绪时触发
  // 调用 getUserInfo API 获取已登录用户的基本信息，详细文档参见https://open.feishu.cn/document/uYjL24iN/ucjMx4yNyEjL3ITM
  tt.getUserInfo({
    // getUserInfo API 调用成功回调
    success(res) {
      console.log(`getUserInfo success: ${JSON.stringify(res)}`);
      // 单独定义的函数showUser，用于将用户信息展示在前端页面上
      showUser(res.userInfo);
    },
    // getUserInfo API 调用失败回调
    fail(err) {
      console.log(`getUserInfo failed:`, JSON.stringify(err));
    },
  });
  // 调用 showToast API 弹出全局提示框，详细文档参见https://open.feishu.cn/document/uAjLw4CM/uYjL24iN/block/api/showtoast
  tt.showToast({
    title: "鉴权成功",
    icon: "success",
    duration: 3000,
    success(res) {
      console.log("showToast 调用成功", res.errMsg);
    },
    fail(res) {
      console.log("showToast 调用失败", res.errMsg);
    },
    complete(res) {
      console.log("showToast 调用结束", res.errMsg);
    },
  });
});
```

代码中涉及的 tt 系接口规则如下：

- 所有接口均为异步接口。

- 除了部分特殊接口（例如，[requestAuthCode](https://open.feishu.cn/document/uYjL24iN/uUzMuUzMuUzM/20220308)、[closeWindow](https://open.feishu.cn/document/uYjL24iN/uYTOuYTOuYTO/closewindow)），其他接口均需要鉴权成功后才可以调用。

- 所有接口必须在`window.h5sdk.ready(function(){})`回调函数触发后调用。

- 需输入 object 类型的参数。

- 成功回调 success，失败回调 fail。

```js
  window.tt.方法({
      参数1：'',
      参数2：''，
      success: function(result) {
           // 成功回调     
      },
      fail: function(error) {
           // 失败回调     
        }
  })
  ```