Skip to content

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。

请求体字段类型说明
edusohoAccessKeystring与请求头一致的 EduSoho 客户标识
edusohoBaseUrlstring已绑定的 EduSoho 站点地址,建议始终传入以明确绑定
modulestring本场景传 drill-tasks
usernamestring当前已授权的 EduSoho 管理用户名称
sourceUserIdstring当前 EduSoho 用户 ID,不是 AI 对练内部用户 ID
expiresAtlongticket 过期时间,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。完整数据查询参数见 对练数据接口 。