Appearance
完整示例
三种起步方式,代码都可以直接复制运行:先用 curl 把协议走通,再用 Node.js 或 Python 搭一个最小但结构完整的后端(免登 + 会话 + 调用开放 能力),前端页面是同一份 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.htmlbash
npm init -y && npm i express
JLT_BASE=https://<api域名>/api/open JLT_APP_KEY=oak_xxx JLT_APP_SECRET=xxx node server.jsserver.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 httpx 后 JLT_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()换新码重试(见上)。