Skip to content

完整示例

三种起步方式,代码都可以直接复制运行:先用 curl 把协议走通,再用 Node.jsPython 搭一个最小但结构完整的后端(免登 + 会话 + 调用开放 能力),前端页面是同一份 HTML。

要给页面加 AI 聊天窗?前端不用手写——SDK 的 mountChat 一行挂载;全部能力串起来的完整项目见 端到端 Demo

准备:在客户端「组织」→「应用」里新建应用,页面地址本地调试填 http://localhost:3000(仅 localhost 允许 http,正式环境必须 https),拿到 AppKey / AppSecret

1. curl:先把协议走通

bash
BASE=https://<你的机灵兔部署域名>/api/open

# ① 换 app access token(有效期 2 小时,请缓存)
curl -s $BASE/auth/token -H 'Content-Type: application/json' \
  -d '{"app_key":"oak_xxx","app_secret":"xxx"}'
# → {"access_token":"...","token_type":"Bearer","expires_in":7200}
TOKEN=eyJ...   # 填入上一步拿到的 token

# ② 免登:authCode 换用户身份(码在客户端打开应用时的页面 URL 上,5 分钟内有效、一次性)
curl -s $BASE/auth/user -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"auth_code":"xxx"}'
# → {"user_id":"u_1","nickname":"张三","avatar_url":"","org_id":"org_1","org_role":"member","team_ids":[...]}

# ③ 通讯录(需要 contacts 能力)
curl -s "$BASE/org/members?offset=0&limit=50" -H "Authorization: Bearer $TOKEN"

# ③' 组织架构:团队树 + 某团队的成员(同样走 contacts 能力)
curl -s "$BASE/org/teams" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/org/teams/<team_id>/members" -H "Authorization: Bearer $TOKEN"

# ③'' 数据库:绑定清单 / 表结构 / 只读查询(需要 db 能力,管理员先在应用设置里绑定)
curl -s "$BASE/db/databases" -H "Authorization: Bearer $TOKEN"
curl -s "$BASE/db/<db_id>/tables" -H "Authorization: Bearer $TOKEN"
curl -s $BASE/db/query -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"db_id":"<db_id>","sql":"SELECT status, COUNT(*) FROM orders GROUP BY status","limit":100}'

# ④ 给成员发通知(需要 im 能力,消息带【应用名】前缀)
curl -s $BASE/im/send -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"user_ids":["u_1"],"content":"你有一条新的审批待处理"}'

# ⑤ AI 对话(需要 ai 能力,消耗应用所属组织的积分)
curl -s $BASE/ai/completions -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"messages":[{"role":"user","content":"一句话介绍报销流程"}]}'

# ⑤' 流式版(SSE,OpenAI 兼容的 data: 块,结尾 data: [DONE])
curl -N -s $BASE/ai/completions -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"messages":[{"role":"user","content":"写一首关于报销的打油诗"}],"stream":true}'

# ⑥ 知识库检索(需要 kb 能力,且管理员已在应用设置里绑定知识库)
curl -s $BASE/kb/search -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"query":"报销流程"}'

2. Node.js 后端(Express)

目录结构:

my-app/
├── server.js
└── public/index.html
bash
npm init -y && npm i express
JLT_BASE=https://<api域名>/api/open JLT_APP_KEY=oak_xxx JLT_APP_SECRET=xxx node server.js

server.js

js
// Node 18+(自带 fetch)。最小但结构完整的组织应用后端:
// app token 缓存 → 免登换身份 → 自建会话 → 代理开放能力。
import express from "express";
import crypto from "node:crypto";

const BASE = process.env.JLT_BASE;
const KEY = process.env.JLT_APP_KEY;
const SECRET = process.env.JLT_APP_SECRET;

// ── app access token 缓存(2 小时有效,提前 1 分钟刷新)──
const tokenCache = { token: "", exp: 0 };
async function appToken() {
  if (Date.now() < tokenCache.exp) return tokenCache.token;
  const r = await fetch(`${BASE}/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ app_key: KEY, app_secret: SECRET }),
  });
  if (!r.ok) throw new Error(`换 app token 失败(${r.status}):${await r.text()}`);
  const d = await r.json();
  tokenCache.token = d.access_token;
  tokenCache.exp = Date.now() + (d.expires_in - 60) * 1000;
  return d.access_token;
}

// 开放 API 调用封装:自动带 Bearer,非 2xx 抛错(detail 是平台给的中文说明)
async function jlt(path, opts = {}) {
  const r = await fetch(`${BASE}${path}`, {
    ...opts,
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${await appToken()}` },
  });
  const body = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(`${path} ${r.status}:${body.detail ?? ""}`);
  return body;
}

const app = express();
app.use(express.json());
app.use(express.static("public"));

// 演示用内存会话;生产环境请换成你自己的会话体系(cookie/JWT 均可)
const sessions = new Map();
function auth(req, res, next) {
  const user = sessions.get(req.header("x-session-id") ?? "");
  if (!user) return res.status(401).json({ detail: "未登录" });
  req.user = user;
  next();
}

// ① 免登:前端把页面 URL 上的 jlt_auth_code 传上来
app.post("/api/login", async (req, res, next) => {
  try {
    const user = await jlt("/auth/user", {
      method: "POST",
      body: JSON.stringify({ auth_code: String(req.body.auth_code ?? "") }),
    });
    const sid = crypto.randomUUID();
    sessions.set(sid, user);
    res.json({ session_id: sid, user });
  } catch (e) { next(e); }
});

// ② 通讯录(contacts)
app.get("/api/members", auth, async (req, res, next) => {
  try {
    res.json(await jlt(`/org/members?offset=0&limit=100`));
  } catch (e) { next(e); }
});

// ③ 给成员发通知(im)
app.post("/api/notify", auth, async (req, res, next) => {
  try {
    res.json(await jlt("/im/send", {
      method: "POST",
      body: JSON.stringify({ user_ids: req.body.user_ids, content: req.body.content }),
    }));
  } catch (e) { next(e); }
});

app.use((err, _req, res, _next) => {
  console.error(err);
  res.status(502).json({ detail: err.message });
});

app.listen(3000, () => console.log("http://localhost:3000(应用注册的页面地址填这个)"));

3. Python 后端(FastAPI)

同样的目录结构,pip install fastapi uvicorn httpxJLT_APP_KEY=oak_xxx JLT_APP_SECRET=xxx uvicorn main:app --port 3000

main.py

python
# Python 3.10+。与 Node 版等价:app token 缓存 → 免登 → 会话 → 代理开放能力。
import os
import time
import uuid

import httpx
from fastapi import FastAPI, Header, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

BASE = os.environ["JLT_BASE"]  # https://<api域名>/api/open
KEY = os.environ["JLT_APP_KEY"]
SECRET = os.environ["JLT_APP_SECRET"]

app = FastAPI()

# ── app access token 缓存(2 小时有效,提前 1 分钟刷新)──
_token: dict = {"t": "", "exp": 0.0}


async def app_token() -> str:
    if time.time() < _token["exp"]:
        return _token["t"]
    async with httpx.AsyncClient() as c:
        r = await c.post(
            f"{BASE}/auth/token", json={"app_key": KEY, "app_secret": SECRET}
        )
        r.raise_for_status()
        d = r.json()
    _token.update(t=d["access_token"], exp=time.time() + d["expires_in"] - 60)
    return _token["t"]


async def jlt(path: str, method: str = "GET", json_body: dict | None = None) -> dict:
    async with httpx.AsyncClient(timeout=30) as c:
        r = await c.request(
            method,
            f"{BASE}{path}",
            headers={"Authorization": f"Bearer {await app_token()}"},
            json=json_body,
        )
    if r.status_code >= 400:
        raise HTTPException(status_code=502, detail=r.json().get("detail", r.text))
    return r.json()


# 演示用内存会话;生产环境请换成你自己的会话体系
sessions: dict[str, dict] = {}


class LoginIn(BaseModel):
    auth_code: str


@app.post("/api/login")
async def login(body: LoginIn):
    user = await jlt("/auth/user", method="POST", json_body={"auth_code": body.auth_code})
    sid = uuid.uuid4().hex
    sessions[sid] = user
    return {"session_id": sid, "user": user}


@app.get("/api/members")
async def members(x_session_id: str = Header()):
    if x_session_id not in sessions:
        raise HTTPException(status_code=401, detail="未登录")
    return await jlt("/org/members?offset=0&limit=100")


# 静态页面挂在 API 路由之后
app.mount("/", StaticFiles(directory="public", html=True), name="static")

4. 前端页面(两个后端通用)

public/index.html——免登 + 列通讯录 + JSAPI 标题/问候,不在客户端里打开时 降级为普通网页:

html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>审批中心</title>
  <!-- JSAPI 由平台提供,页面不可自带副本 -->
  <script src="https://<api域名>/api/open/jsapi/jlt.js"></script>
</head>
<body>
  <h1 id="hello">正在登录…</h1>
  <ul id="members"></ul>
  <script>
    const qs = new URLSearchParams(location.search);
    const authCode = qs.get("jlt_auth_code");

    // 用一枚免登码登录,成功后把会话 id 存到 localStorage
    async function login(code) {
      const r = await fetch("/api/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ auth_code: code }),
      }).then((x) => x.json());
      if (!r.session_id) throw new Error(r.detail || "登录失败");
      localStorage.setItem("sid", r.session_id);
      return r.session_id;
    }

    async function main() {
      // ① 首次打开:用 URL 上的免登码登录(一次性,用完即从地址栏清掉)
      let sid = localStorage.getItem("sid");
      if (authCode) {
        sid = await login(authCode);
        history.replaceState(null, "", location.pathname);
      }

      // ② 经自己的后端拿通讯录(后端再去调开放 API);会话失效则在客户端里
      //    用 requestAuthCode() 拿新码重登(比如你的后端重启过)
      let d = await fetch("/api/members", { headers: { "x-session-id": sid } })
        .then((x) => x.json().then((b) => ({ ok: x.ok, ...b })));
      if (!d.members && window.jlt) {
        const { auth_code } = await jlt.requestAuthCode();
        sid = await login(auth_code);
        d = await fetch("/api/members", { headers: { "x-session-id": sid } }).then((x) => x.json());
      }
      document.getElementById("hello").textContent = `共 ${d.members.length} 位成员`;
      document.getElementById("members").innerHTML = d.members
        .map((m) => `<li>${m.nickname || m.user_id}(${m.role})</li>`)
        .join("");

      // ③ JSAPI:客户端里显示真实身份、设置窗口标题;普通浏览器则静默降级
      if (window.jlt) {
        jlt.ready((ctx) => {
          document.getElementById("hello").textContent =
            `你好,${ctx.user.nickname}(组织 ${ctx.org_id})`;
          jlt.setTitle("审批中心");
        });
      }
    }
    main().catch((e) => {
      document.getElementById("hello").textContent = `加载失败:${e.message}`;
    });
  </script>
</body>
</html>

会话续期

免登码只在首次打开和登录失败时需要。如果你的会话有时效,可以在快过期时用 jlt.requestAuthCode() 重新取一枚码走一遍 /api/login

本地调试要点

  • 页面地址注册 http://localhost:3000 即可调试(仅 localhost / 127.0.0.1 放行 http,正式环境必须 https);
  • 客户端内嵌的是你的页面,浏览器 DevTools 不方便直接用,建议后端逻辑先 用 curl/单测验证,页面侧问题用 console.log + 客户端重新打开来排查;
  • 免登码 5 分钟一次性:刷新页面会失败,属于预期——页面应在失败时用 requestAuthCode() 换新码重试(见上)。

机灵兔开放平台 · 组织应用开发者文档