Appearance
AI 对练接口鉴权
服务与适用范围
AI 对练是独立服务,域名为 aicoach.edusoho.com,本文接口使用 /api/v1 前缀,不是 PHP 主站 /api 或 LMS /next 接口。
数据查询接口见 对练数据接口 。本页介绍用户 JWT 鉴权,不适用于主数据同步、课程网关或开放指标接口的签名协议。
通过 EduSoho 获取令牌
本接入场景的组织和用户主数据来自 EduSoho,客户不需要在 AI 对练设置账号密码。由可信的 EduSoho 服务端申请管理端 SSO ticket,再兑换 AI 对练访问令牌,最后使用令牌请求数据接口。
TypeScript 调用脚本
仓库提供 examples/ai-coach-query.mts,使用 Node.js 22.18+ 原生运行,无需安装依赖。在项目根目录执行:
bash
node examples/ai-coach-query.mts \
--ak your_access_key \
--sk your_sync_secret \
--domain aicoach.edusoho.com \
--username authorized_admin \
--source-user-id 123 \
--edusoho-url https://your-edusoho.example.com--domain 是 AI 对练域名,--edusoho-url 是绑定的 EduSoho 站点地址;存在多个绑定时应传后者。管理用户名和用户 ID 均为必填参数。--sk 必须使用绑定的同步密钥 syncSecret,不要混用其他开放接口的 SecretKey。
your_sync_secret 为占位值,实际同步密钥需联系客服获取,不是 EduSoho 管理员密码。
命令行传密钥会暴露在历史或进程参数中,实际使用推荐由安全运行环境注入 EDUSOHO_SYNC_SECRET 并省略 --sk;AK 也可通过 EDUSOHO_ACCESS_KEY 注入。脚本默认输出第一页、每页 20 条对练记录 JSON,不打印 ticket、访问令牌或密钥。输出包含客户数据,应妥善保存。
脚本不会自动重试,不会修改任务或对练记录,但管理端 SSO 兑换本身可能补齐管理员角色,必须使用已授权管理用户。
接入前提
- EduSoho 站点已与 AI 对练租户建立有效绑定,并配置匹配的
accessKey和syncSecret。 - 对应 EduSoho 用户已同步到 AI 对练;推荐通过
sourceUserId匹配,未匹配时实现会回退到同租户用户名查找。 - EduSoho 服务端必须先确认当前用户有后台管理权限,不能允许普通学员任意申请管理端 ticket。当前兑换实现会为对应用户补齐
ROLE_ADMIN,该链路不是只读服务凭据。 - 密钥仅保存在可信服务端,不发送到浏览器,不交给无授权的第三方,也不写入日志。
第一步:EduSoho 服务端申请 ticket
http
POST https://aicoach.edusoho.com/api/v1/integrations/edusoho/admin-sso-ticket
Content-Type: application/json| 请求头 | 说明 |
|---|---|
| X-Edusoho-Access-Key | 已绑定的 EduSoho accessKey |
| X-Edusoho-Timestamp | 当前 Unix 时间戳,单位为秒;允许与服务端相差不超过 300 秒 |
| X-Edusoho-Nonce | 每次请求生成的唯一随机字符串,重试也需重新生成 |
| X-Edusoho-Signature | 下述签名结果,小写十六进制字符串 |
签名原文为 accessKey + timestamp + nonce + body,直接拼接,无分隔符。使用 syncSecret 作为密钥计算 HMAC-SHA256;原文按 UTF-8 编码,结果转为小写十六进制。签名使用的 body 必须与实际发送的请求体完全一致,签名后不要重新格式化 JSON。
| 请求体字段 | 类型 | 说明 |
|---|---|---|
| edusohoAccessKey | string | 与请求头一致的 EduSoho 客户标识 |
| edusohoBaseUrl | string | 已绑定的 EduSoho 站点地址,建议始终传入以明确绑定 |
| module | string | 本场景传 drill-tasks |
| username | string | 当前已授权的 EduSoho 管理用户名称 |
| sourceUserId | string | 当前 EduSoho 用户 ID,不是 AI 对练内部用户 ID |
| expiresAt | long | ticket 过期时间,Unix 秒时间戳;必须晚于当前时间,建议设置为当前时间加 60 秒 |
以下为 ai-coach-query.mts 完整源码,包含签名申请 ticket、兑换令牌和查询对练记录。凭据通过命令行参数或运行环境提供,不硬编码到源码中。
ts
import { createHmac, randomUUID } from 'node:crypto';
import { parseArgs } from 'node:util';
import { pathToFileURL } from 'node:url';
type Options = {
ak: string;
sk: string;
domain: string;
username: string;
sourceUserId: string;
edusohoUrl?: string;
};
class DiagnosticError extends Error {}
export async function queryRecords(options: Options, request: typeof fetch = fetch) {
for (const key of ['ak', 'sk', 'domain', 'username', 'sourceUserId'] as const) {
if (!options[key]?.trim()) throw new DiagnosticError(`Missing ${key}; use --help`);
}
const origin = new URL(options.domain.includes('://') ? options.domain : `https://${options.domain}`);
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) {
throw new DiagnosticError('Domain must be an HTTPS origin without credentials, path or query');
}
async function json(path: string, init: RequestInit) {
let response: Response;
try {
response = await request(new URL(path, origin), {
...init, redirect: 'error', signal: AbortSignal.timeout(30_000)
});
} catch (error) {
const cause = (error as { cause?: { code?: string } })?.cause?.code;
const known = ['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'];
const reason = cause && known.includes(cause) ? cause
: error instanceof Error && error.name === 'TimeoutError' ? 'TIMEOUT' : 'FETCH_FAILED';
throw new DiagnosticError(`${path}: network failure (${reason})`);
}
let payload;
try { payload = await response.json(); } catch {
throw new DiagnosticError(`${path}: HTTP ${response.status}${response.ok ? ', non-JSON response' : ''}`);
}
if (!response.ok) {
// Only expose bounded metadata, never server messages or complete bodies.
const safe = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value)
&& ![options.ak, options.sk].some(secret => value.includes(secret)) ? value : undefined;
const code = safe(payload?.code);
const reqId = safe(payload?.reqId);
throw new DiagnosticError(`${path}: HTTP ${response.status}${code ? ` code=${code}` : ''}${reqId ? ` reqId=${reqId}` : ''}`);
}
return payload;
}
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomUUID();
const body = JSON.stringify({
edusohoAccessKey: options.ak,
edusohoBaseUrl: options.edusohoUrl,
module: 'drill-tasks',
username: options.username,
sourceUserId: options.sourceUserId,
expiresAt: Number(timestamp) + 60
});
const signature = createHmac('sha256', options.sk)
.update(options.ak + timestamp + nonce + body, 'utf8').digest('hex');
const ticketResult = await json('/api/v1/integrations/edusoho/admin-sso-ticket', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Edusoho-Access-Key': options.ak,
'X-Edusoho-Timestamp': timestamp,
'X-Edusoho-Nonce': nonce,
'X-Edusoho-Signature': signature
},
body
});
if (ticketResult?.code !== 'Ok' || !ticketResult.data?.ticket) throw new DiagnosticError('Ticket: invalid response');
const tokenResult = await json('/api/v1/integrations/edusoho/admin-sso-exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket: ticketResult.data.ticket })
});
if (tokenResult?.code !== 'Ok' || !tokenResult.data?.accessToken) throw new DiagnosticError('Token: invalid response');
return json('/api/v1/admin/drill-task/record/search?page=1&size=20', {
headers: { Authorization: `Bearer ${tokenResult.data.accessToken}` }
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
const { values } = parseArgs({ options: {
ak: { type: 'string' }, sk: { type: 'string' }, domain: { type: 'string' },
username: { type: 'string' }, 'source-user-id': { type: 'string' },
'edusoho-url': { type: 'string' }, help: { type: 'boolean' }
} });
if (values.help) {
console.log('node examples/ai-coach-query.mts --ak <ak> --domain aicoach.edusoho.com --username <admin> --source-user-id <id> [--sk <secret>] [--edusoho-url <url>]\nCredentials can also use EDUSOHO_ACCESS_KEY and EDUSOHO_SYNC_SECRET. Requires Node.js 22.18+.');
} else {
const data = await queryRecords({
ak: values.ak ?? process.env.EDUSOHO_ACCESS_KEY ?? '',
sk: values.sk ?? process.env.EDUSOHO_SYNC_SECRET ?? '',
domain: values.domain ?? '', username: values.username ?? '',
sourceUserId: values['source-user-id'] ?? '', edusohoUrl: values['edusoho-url']
});
console.log(JSON.stringify(data, null, 2));
}
} catch (error) {
console.error(`Query failed: ${error instanceof DiagnosticError ? error.message : 'Invalid arguments, invalid domain or unexpected failure; use --help'}`);
process.exitCode = 1;
}
}成功响应为 { "code": "Ok", "data": { "ticket": "...", "expiresAt": 1794355260, "redirect": "/admin/drill-tasks?embed=edusoho" } },还可能包含 message 字段。立即兑换 ticket,不记录或长期保存。
第二步:兑换 AI 对练令牌
http
POST https://aicoach.edusoho.com/api/v1/integrations/edusoho/admin-sso-exchange
Content-Type: application/json
{"ticket":"your_sso_ticket"}此步骤使用 ticket,不需要账号密码或上述签名请求头。成功响应如下。
json
{
"code": "Ok",
"data": {
"accessToken": "your_access_token",
"refreshToken": "your_refresh_token",
"redirect": "/admin/drill-tasks?embed=edusoho"
}
}注意令牌在 data.accessToken 和 data.refreshToken 中。该响应没有生命周期字段;不需要访问 redirect 页面即可用令牌调用数据接口。
第三步:使用令牌查询对练数据
bash
curl 'https://aicoach.edusoho.com/api/v1/admin/drill-task/record/search?page=1&size=20' \
-H "Authorization: Bearer ${ACCESS_TOKEN}"ACCESS_TOKEN 使用第二步返回的 data.accessToken。完整数据查询参数见 对练数据接口 。