# Webinars Open API --- --- title: 开始对接 description: 获取凭证、完成签名,接入观众登录与观看数据。 --- # 开始对接 通过 Webinars Open API,把你系统中的用户接入直播观看页,并查询他们的有效观看时长。当前提供 **4 个接口**,所有请求均使用 **POST + JSON + HMAC-SHA256**。 ## 第一次调用 1. 在商家控制台选择当前商户,进入 **开发者 → 开放 API**,取得 [API Key 和 Secret](./guide/api-key.md)。 2. 取得部署方提供的 API 基础地址。**文档站域名与 API 域名是两个入口**;不能把当前文档域名直接当成 API 地址。 3. 按 [签名算法](./guide/signature.md) 构造请求,调用「创建或更新外部观众」,保存返回的 `uid`。 4. 用 `uid` 和活动 `ticketCode` 签发登录票据,将 `loginUrl` 交给该用户打开。 5. 需要观看统计时,查询概览或观众明细。数据成熟度及明细查询时机见各接口说明。 ## 接口目录 | 接口 | 使用场景 | 完整 Path | |---|---|---| | [创建或更新外部观众](./api/upsert-viewer.md) | 外部用户 → 观众 UID | `POST /api/v1/open/viewers/upsert` | | [签发一次性登录票据](./api/login-ticket.md) | 观众 UID → 登录观看链接 | `POST /api/v1/open/viewers/login-tickets` | | [查询观看数据概览](./api/watch-overview.md) | 活动整体观看统计 | `POST /api/v1/open/watch-data/overview` | | [查询观众观看明细](./api/watch-viewers.md) | 指定观众的时长及会话统计 | `POST /api/v1/open/watch-data/viewers/query` | ## 三个标识不要混淆 | 标识 | 来源 | 用途 | |---|---|---| | `externalUserId` | 你系统中的稳定用户标识 | 在当前商户下绑定观众,区分大小写 | | `uid` | 创建或更新观众接口返回 | 当前商户内的数字观众编号,JSON 数字 | | `ticketCode` | 商家控制台的活动观看链接,如 `/w/abc123` 中的 `abc123` | 定位活动;不是控制台内部的活动 ID | API Key 决定商户身份,请求不需要 `X-Tenant-ID`、商家 Cookie 或 JWT。不同商户之间不能共用观众映射;跨商户活动查询按不存在处理。 ## 文档怎么读 先看公共说明,接口页只保留其特有参数和行为。每页支持标题锚点、代码复制和 Markdown 阅读。[AI/LLM 文档索引](/llms.txt) 是机器可读的文档目录,[完整文本](/llms-full.txt) 是合并后的全部内容,主要供 AI 和无需执行 JavaScript 的工具读取。 [在线调试](./debug.md) 只向当前部署环境发送真实请求。环境与 API 地址未配置时仍能阅读文档和复制示例;实际可用性需由部署方核验。 --- # 获取 API Key ## 在商家控制台获取 [打开商家控制台](https://b.t.robot-daily.com) 1. 使用有权限的商家账号登录控制台,并选择要对接的商户。 2. 打开 **开发者 → 开放 API**(控制台页面路径 `/api-keys`)。 3. 点击 **创建 API Key**,填写便于识别的名称,例如「测试环境 CRM」。 4. 复制 **API Key** 和 **Secret**,分别配置到你服务端的环境变量或凭证管理系统。 当前实现的列表页也可查看和复制已有 Secret;不要误以为它只在创建时展示。无法看到页面或凭证时,联系商家账号管理者核对权限,不要借用其他商户的凭证。 ## 两个值各做什么 | 名称 | 用途 | 是否放到请求里 | |---|---|---| | API Key | 识别调用凭证及所属商户 | `X-API-Key` 请求头 | | Secret | 本地计算 HMAC 签名 | **不发送**,只发送算出的 `X-Signature` | Secret 不是 API Key,也不是商家登录密码。复制时不要带入首尾空白。 ## 保管与撤销 - 测试和生产使用各自环境中的凭证;建议为不同用途创建独立凭证,便于撤销。 - 常规业务调用放在你的服务端,不把 Secret 嵌入网页、移动 App、源码仓库或日志。 - 凭证泄露时在控制台撤销旧 Key,并创建新 Key 替换服务端配置。撤销后旧 Key 不能再签名调用。 - [在线调试](../debug.md) 是临时排查工具:输入 Secret 后它会出现在当前浏览器内存中,只在可信设备上使用;调试结束点击「清除凭证与结果」。 控制台的凭证管理接口属于商家后台,不是本套 Open API 的接口;对接系统通过管理员取得凭证后再调用开放接口。 --- # 请求与响应 ## 请求地址 | 当前环境 | API Base URL | |---|---| | 测试环境 | https://b.t.robot-daily.com | ```text {API_BASE_URL}{完整 Path} 例如:https://api.example.com/api/v1/open/viewers/upsert ``` `https://api.example.com` 是占位示例,请替换为部署方提供的当前环境 API Origin。所有接口使用 HTTPS、POST、UTF-8 JSON;不带 URL 查询参数,不在 Path 后添加 `/`,也不对 Path 做百分号编码。 ## 公共请求头 | 请求头 | 必填 | 格式 | |---|---|---| | `Content-Type` | 是 | `application/json` | | `X-API-Key` | 是 | 当前商户有效的 API Key | | `X-Timestamp` | 是 | Unix 时间戳,单位**秒**,十进制字符串;不是毫秒 | | `X-Nonce` | 是 | 每次请求新生成,16–128 字符,只含 `A-Z a-z 0-9 _ -`;推荐 16 个随机字节的 32 位 hex | | `X-Signature` | 是 | HMAC-SHA256 结果,64 位**小写** hex;见 [签名算法](./signature.md) | 每个签名头只能出现一次。请求体不压缩,`Content-Encoding` 省略或为 `identity`。API Key 自带商户身份,不传 `X-Tenant-ID`,无需 Cookie 或 Authorization。 当前示例配置允许时间偏差 ±300 秒、请求体最多 1 MiB;这些是可配置值,实际环境以部署方约定为准。调用服务器应同步时钟。 ## 参数约定 - JSON 字段名区分大小写,不允许额外未知字段,且请求体只能包含一个 JSON 对象。 - `integer` 是 JSON 非负整数;UID 目前为 10000–9999999,可安全作为 JavaScript 数字使用。 - 时长单位为秒,时间字符串为 UTC RFC3339(`2026-10-12T08:00:00Z`)。 - 参数表中的「必填」按 HTTP 实际校验标注。观看查询的 `playbackMode`、明细的 `sort` 和 `pageSize` 必须显式填写,不能依赖默认值。 - `null`、省略与空字符串可能有不同含义,具体看对应字段。响应「始终返回:否」表示字段可能省略,不保证返回 null。 ## 成功响应 HTTP 200,格式为: ```json {"requestId":"req-example","data":{"viewer":{"uid":10000,"externalUserId":"customer-10001","phone":null,"displayName":"张三"},"created":true}} ``` 没有统一的 `code: 0` 或 `success: true`。业务结果在 `data`;各页分别说明内部结构。 ## 错误响应 使用非 2xx HTTP 状态,通常为 JSON: ```json {"requestId":"req-example","error":{"code":"OPEN_API_UNAUTHORIZED","message":"Open API 认证失败"}} ``` `error.details` 是可选附加信息。程序判断 `error.code`,不要依赖中文 `message`。代理、TLS 或网络错误也可能没有 JSON 响应。 [错误码与排查](./errors.md) 提供处理建议。任何重试都需要新的 timestamp、nonce 和签名;网络超时不代表业务未执行。 --- # 签名算法 ## 计算步骤 1. 准备最终要发送的 UTF-8 JSON 文本 `rawBody`。序列化**一次**,后面签名和发送使用同一份字节。 2. 计算 `bodyHash = lowercaseHex(SHA256(rawBody))`。 3. 按下方顺序用换行符 `\n` 拼接六行,**末尾不加换行**。 4. 以 Secret 的 UTF-8 字节为密钥计算 HMAC-SHA256,并编码为 64 位小写 hex,填入 `X-Signature`。 ```text API Key POST 完整 Path(不含域名) X-Timestamp X-Nonce bodyHash ``` 公式: ```text canonical = apiKey + "\n" + "POST" + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + lowercaseHex(SHA256(rawBody)) signature = lowercaseHex(HMAC_SHA256(UTF8(secret), UTF8(canonical))) ``` Secret 作为原始文本使用,**不先做 hex/Base64 解码**。示例代码见各接口的「调用示例」,可直接复制完整脚本。 ## 固定校验向量 此例只验证计算算法;时间戳已过期,不能用来调用线上 API。 | 输入 | 值 | |---|---| | API Key | `key` | | Secret | `secret` | | Method | `POST` | | Path | `/api/v1/open/viewers/upsert` | | Timestamp | `1700000000` | | Nonce | `0123456789abcdef` | | 原始 Body | `{}`(两个字符,无空格和换行) | | bodyHash | `44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a` | | Signature | `b04fca374e3428b338b6bcc0597f81762d99b21235971184293b9252d304ee65` | ## 常见失败原因 - **签名后重新序列化 JSON**:空格、字段顺序、换行和中文转义的变化都会改变哈希。 - 使用毫秒时间戳或服务器时钟偏差过大。 - 将完整 URL 放进签名,或签名 Path 与发送 Path 不一致。 - HMAC 结果使用 Base64 或大写 hex。 - 请求经过重定向或中间代理重新编码、压缩请求体。 - 重用同一 nonce。nonce 防重放不是业务幂等键,即使上次业务校验失败,也应为新请求生成新 nonce。 不要打印 Secret、完整签名串和包含观众资料的原始 Body。排查时使用本页固定向量,再携带 `requestId` 联系部署方。 --- # 错误码与排查 ## 公共错误 | HTTP | error.code | 原因与处理 | |---|---|---| | 400 | `REQUEST_INVALID` | 缺失/重复签名头、非法 nonce、未知 JSON 字段、请求体超限或参数不符约束;核对参数表 | | 401 | `OPEN_API_UNAUTHORIZED` | Key 不存在/被撤销、签名不匹配、时间偏差超限或 Path 非规范;使用固定向量检查算法 | | 403 | `ORIGIN_FORBIDDEN` | 在线调试的文档 Origin 未获 CORS 许可;由部署方配置允许来源 | | 403 | `TENANT_SUSPENDED` | 商户已停用;联系管理员 | | 409 | `OPEN_API_REPLAYED` | nonce 已使用;重新生成 timestamp、nonce 和签名 | | 503 | `AUTH_DEPENDENCY_UNAVAILABLE` | 鉴权、观众存储或票据依赖暂不可用;联系部署方,并为重试重新签名 | | 500 | `INTERNAL_ERROR` | 未预期服务错误;提供 requestId 排查 | 网关或 HTTP 服务也可能以 413 拒绝超大请求,且不一定返回 JSON;Open API 签名中间件发现请求体超限时返回 400 `REQUEST_INVALID`。 ## 接口特有错误 | HTTP | error.code | 适用接口 | 处理 | |---|---|---|---| | 400 | `CONTENT_SENSITIVE` | 创建或更新观众 | 昵称被内容审核拒绝,修改后重试;可含 `details.matches` | | 503 | `CONTENT_MODERATION_UNAVAILABLE` | 创建或更新观众 | 启用的内容审核服务不可用,稍后重试 | | 409 | `VIEWER_IDENTITY_CONFLICT` | 创建或更新观众 | 手机号被同商户其他观众占用,包括新外部标识试图关联已有手机号;改用未占用的号码,不自动合并 | | 409 | `VIEWER_UID_EXHAUSTED` | 创建或更新观众 | 商户观众 UID 容量已达上限,联系部署方 | | 404 | `VIEWER_NOT_FOUND` | 登录票据 | UID 不属于当前商户或不存在,先建立映射 | | 404 | `WEBINAR_NOT_FOUND` | 登录票据/观看查询 | 公开码不存在或不属于当前商户;签发票据还要求活动已发布 | | 400 | `WATCH_REQUEST_INVALID` | 观众明细 | 筛选合计超过 300,或游标不匹配当前查询;首次查询删除 cursor | | 409 | `WATCH_DATA_NOT_FINAL` | 观众明细 | 查询含未最终落库的直播数据;等待直播最终化,或明确只查询 REPLAY | | 503 | `WATCH_DATA_UNAVAILABLE` | 观看查询 | 数据存储/实时汇总暂不可用或完整性检查未通过;不是零观看数据 | | 503 | `PUBLIC_METADATA_UNAVAILABLE` | 登录票据 | 活动信息暂不可用;提供 requestId 排查 | | 503 | `VIEWER_DOMAIN_UNAVAILABLE` | 登录票据 | 观看域名或其映射暂不可用,联系部署方 | ## 排查顺序 1. 看 HTTP 状态和 `error.code`,保留 `requestId`。 2. 400 先看请求体与字段表;401 使用 [固定签名向量](./signature.md#固定校验向量) 验证算法。 3. 浏览器显示网络失败时,检查 API 地址、证书、CORS 预检与文档 CSP。浏览器未读取到响应不等于服务端未处理请求。 4. 修改参数或重试时,重新生成 timestamp、nonce 和签名。 不要上传 Secret、一次性票据、完整手机号或带票据的链接作为排查附件。只提交脱敏请求、HTTP 状态、error.code 和 requestId。 --- # 在线调试说明 [打开在线调试工作台](../debug.md),可以向当前部署环境的 API 发送真实请求。它是纯前端工具,使用浏览器 Web Crypto 生成签名,不经过文档站服务端代理。环境和 API 地址由发布配置固定,页面不能切换。 ## 操作步骤 1. 确认页面显示的当前环境和完整 API 地址。未启用在线调试时不能发送请求。 2. 选择接口。参数示例会自动填入;改为当前商户的真实 UID、活动公开码或外部用户标识。 3. 输入当前环境的 API Key、Secret。 4. 编辑请求体,点击「签名并发送」。正式环境每次发送前都要确认。 5. 查看 HTTP 状态、JSON 响应和 requestId;结束后点击「清除凭证与结果」。 切换接口、刷新页面或离开页面会清除当前凭证和结果,需要重新填写凭证。 ## 真实请求的影响 - 创建或更新观众会写入实际资料;同一 `externalUserId` 可换绑未占用手机号,手机号不能清空。省略 `displayName` 保留昵称,传 null 清空昵称。 - 登录票据接口签发真实一次性票据,**同一观众再次签发会覆盖原票据**。 - 查询接口会返回真实观看信息;响应中的手机号和外部标识可能涉及个人资料。 - 不自动重试。取消和超时只能停止浏览器等待,不能保证已发送操作被撤销。 ## 凭证边界 API Key 和 Secret 输入框直接显示明文,输入后仅保存在当前页面内存,不保存到 localStorage、Cookie 或服务端日志,也没有统计埋点。但浏览器扩展、共享设备和复制到剪贴板的响应仍可能被他人读取。请在可信设备使用;常规集成继续使用服务端示例。 页面不会发送 Secret;发送的是签名头。浏览器请求不携带 Cookie,不接受重定向,只能连接本次构建配置的一个 API Origin。 ## 无法请求时 文档正文不依赖 API。在线请求还要求 API 已部署,文档及 API 使用 HTTPS,API 的 `HTTP.OpenAPIOrigins` 包含当前文档 Origin,文档 CSP 的 `connect-src` 包含当前 API Origin。 网络、CORS 和 CSP 失败在浏览器中可能都显示为无法读取响应;查看开发者工具,再按 [错误码与排查](./errors.md) 处理。网页本地双击或禁用 JavaScript 时只用于阅读,不能在线调试。 --- --- title: 在线调试 outline: false --- # 在线调试 向当前部署环境发送真实请求。使用前阅读 [在线调试说明](./guide/debugging.md);不确定参数时先查看对应接口页。 在线调试需要浏览器执行 JavaScript,详见 /guide/debugging.html。 --- # 创建或更新外部观众 **POST /api/v1/open/viewers/upsert** 将对接系统用户映射为当前商户的观众,取得稳定的数字 UID。 ## 使用场景 把自己系统的用户标识映射为商户内的观众 UID。后续签发登录票据、查询观看明细都使用这个 UID,或继续使用外部用户标识。 相同商户、相同 `externalUserId` 重复调用会返回已有观众;只有首次创建时 `created=true`。每次调用仍需使用新的 nonce。 ## 请求参数 | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `externalUserId` | string | 是 | trim 后非空,最多 191 个 Unicode 字符,区分大小写;同一商户内唯一。 | | `phone` | string | 否 | 中国大陆手机号,格式 1[3-9] 后接 9 位数字;可换绑同商户未被其他观众占用的号码。省略、空串或纯空白保留原值;不支持清空,null 返回 400。 | | `displayName` | string \| null | 否 | 省略保留;null 清空;trim 后非空且最多 80 个 Unicode 字符,禁止控制字符和换行。 | ## 返回值 | 字段 | 类型 | 始终返回 | 说明 | |---|---|---|---| | `requestId` | string | 是 | 请求追踪编号,排查问题时提供此值。 | | `data` | object | 是 | 业务结果。 | | `data.viewer` | object | 是 | 写入后的观众资料。 | | `data.viewer.uid` | integer | 是 | 当前商户内的数字 UID,10000–9999999。 | | `data.viewer.externalUserId` | string | 是 | 绑定的外部用户标识。 | | `data.viewer.phone` | string \| null | 是 | 绑定手机号;未绑定为 null。 | | `data.viewer.displayName` | string \| null | 是 | 展示昵称;未设置为 null。 | | `data.created` | boolean | 是 | true 新建观众,false 返回或更新已有观众。 | ```json { "requestId": "req-example-001", "data": { "viewer": { "uid": 10000, "externalUserId": "customer-10001", "phone": "13800138000", "displayName": "张三" }, "created": true } } ``` 示例 UID 仅作说明,以实际返回为准。请在自己的系统中保存 `externalUserId → uid` 映射,UID 只在当前商户内有效。 ## 绑定与更新规则 | 操作 | 结果 | |---|---| | 只传外部标识 | 创建或返回对应观众,保留已有手机号和昵称 | | 同一外部标识传入当前手机号 | 幂等成功,返回同一观众 | | 同一外部标识传入未占用的新手机号 | 绑定或换绑成功,UID 和外部标识保持不变 | | 传入同商户其他观众已占用的手机号 | 返回 `VIEWER_IDENTITY_CONFLICT`,不修改任何资料 | | 新外部标识传入同商户已有手机号 | 返回 `VIEWER_IDENTITY_CONFLICT`,即使该手机号观众没有外部标识也不合并 | | `phone` 省略、空字符串或纯空白 | 保留原手机号;新建观众可以不绑定手机号 | | `phone: null` | 返回 400 `REQUEST_INVALID`,已绑定手机号不能清空 | | `displayName` 省略/`null`/字符串 | 分别表示保留/清空/设置昵称 | 外部标识会去除首尾空白,区分大小写;它是商家侧稳定身份标识,不区分观众来源,不支持通过此接口改写。新昵称可能经过内容审核。 换绑成功后旧手机号释放,可以被其他观众使用;该观众已有的观看会话继续有效。不能通过手机号把两个观众合并。 例如,首次以 `customer-10001` 绑定 `13800138000` 后,保持外部标识不变,再发送下列请求即可换绑;旧手机号不再属于该观众: ```json { "externalUserId": "customer-10001", "phone": "13800138001" } ``` ## 相关错误 | HTTP | error.code | 处理方式 | |---|---|---| | 400 | `REQUEST_INVALID` | 检查手机号、昵称和外部标识格式;手机号不能传 null | | 409 | `VIEWER_IDENTITY_CONFLICT` | 手机号被同商户其他观众占用,使用未占用的号码;不要自动合并账户 | | 400 | `CONTENT_SENSITIVE` | 调整昵称后重新请求 | | 503 | `CONTENT_MODERATION_UNAVAILABLE` | 内容审核暂不可用,稍后重新请求 | | 409 | `VIEWER_UID_EXHAUSTED` | 当前商户 UID 分配空间已耗尽,联系平台 | 通用鉴权与错误处理见[签名算法](/guide/signature.md)、[错误码](/guide/errors.md)。 ## 请求示例 先设置当前环境的 `OPEN_API_BASE_URL`、`OPEN_API_KEY`、`OPEN_API_SECRET`,示例会自动签名。不要把真实 Secret 写入代码仓库。 ### cURL ```sh # 先设置 OPEN_API_BASE_URL、OPEN_API_KEY、OPEN_API_SECRET;请使用当前环境的凭证。 set -eu : "${OPEN_API_BASE_URL:?请设置 HTTPS API Origin}" : "${OPEN_API_KEY:?请设置 API Key}" : "${OPEN_API_SECRET:?请设置 Secret}" export OPEN_API_PATH='/api/v1/open/viewers/upsert' export OPEN_API_BODY='{"externalUserId":"customer-10001","phone":"13800138000","displayName":"张三"}' export OPEN_API_TIMESTAMP="$(date +%s)" export OPEN_API_NONCE="$(python3 -c 'import secrets; print(secrets.token_hex(16))')" SIGNATURE="$(python3 - <<'PY' import hashlib, hmac, os body = os.environ['OPEN_API_BODY'].encode('utf-8') canonical = '\n'.join([os.environ['OPEN_API_KEY'], 'POST', os.environ['OPEN_API_PATH'], os.environ['OPEN_API_TIMESTAMP'], os.environ['OPEN_API_NONCE'], hashlib.sha256(body).hexdigest()]) print(hmac.new(os.environ['OPEN_API_SECRET'].encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()) PY )" # 不使用 -v 或调试日志;响应可能含观众资料或一次性登录票据。 curl --silent --show-error --max-time 15 \ --request POST "${OPEN_API_BASE_URL%/}${OPEN_API_PATH}" \ --header 'Content-Type: application/json' \ --header "X-API-Key: ${OPEN_API_KEY}" \ --header "X-Timestamp: ${OPEN_API_TIMESTAMP}" \ --header "X-Nonce: ${OPEN_API_NONCE}" \ --header "X-Signature: ${SIGNATURE}" \ --data-binary "${OPEN_API_BODY}" ``` ### Java ```java // Java 17+;只使用标准库。 import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Duration; import java.util.HexFormat; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; class OpenApiExample { private static String requiredEnv(String name) { String value = System.getenv(name); if (value == null || value.isBlank()) throw new IllegalArgumentException("请设置环境变量 " + name); return value; } public static void main(String[] args) throws Exception { String baseURL = requiredEnv("OPEN_API_BASE_URL").replaceFirst("/+$", ""); String apiKey = requiredEnv("OPEN_API_KEY"); String secret = requiredEnv("OPEN_API_SECRET"); String path = "/api/v1/open/viewers/upsert"; String rawBody = "{\"externalUserId\":\"customer-10001\",\"phone\":\"13800138000\",\"displayName\":\"张三\"}"; byte[] bodyBytes = rawBody.getBytes(StandardCharsets.UTF_8); String timestamp = Long.toString(System.currentTimeMillis() / 1000); byte[] nonceBytes = new byte[16]; new SecureRandom().nextBytes(nonceBytes); String nonce = HexFormat.of().formatHex(nonceBytes); String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bodyBytes)); String canonical = String.join("\n", apiKey, "POST", path, timestamp, nonce, bodyHash); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String signature = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); HttpRequest request = HttpRequest.newBuilder(URI.create(baseURL + path)) .timeout(Duration.ofSeconds(15)) .header("Content-Type", "application/json") .header("X-API-Key", apiKey) .header("X-Timestamp", timestamp) .header("X-Nonce", nonce) .header("X-Signature", signature) .POST(HttpRequest.BodyPublishers.ofByteArray(bodyBytes)) .build(); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .followRedirects(HttpClient.Redirect.NEVER) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); // 仅在本地对接时查看完整响应;正式业务请用项目的 JSON 库解析,禁止记录完整观众资料或登录票据。 System.out.printf("status=%d%n%s%n", response.statusCode(), response.body()); } } ``` ### PHP ```php true, CURLOPT_POSTFIELDS => $rawBody, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-API-Key: ' . $apiKey, 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Signature: ' . $signature, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 15, ]); $responseBody = curl_exec($curl); if ($responseBody === false) { $message = curl_error($curl); curl_close($curl); throw new RuntimeException('请求失败:' . $message); } $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); $result = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); // result['data'] 交给业务处理;不要记录完整响应和票据。 printf("status=%d requestId=%s errorCode=%s\n", $status, $result['requestId'] ?? '', $result['error']['code'] ?? ''); ``` ### Node.js ```mjs // Node.js 22+;使用内置 fetch 和 crypto,无额外依赖。 import { createHash, createHmac, randomBytes } from 'node:crypto' const { OPEN_API_BASE_URL: baseURL, OPEN_API_KEY: apiKey, OPEN_API_SECRET: secret } = process.env if (!baseURL || !apiKey || !secret) throw new Error('请设置环境变量') const path = '/api/v1/open/viewers/upsert' const rawBody = "{\"externalUserId\":\"customer-10001\",\"phone\":\"13800138000\",\"displayName\":\"张三\"}" const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = randomBytes(16).toString('hex') const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('hex') const canonical = [apiKey, 'POST', path, timestamp, nonce, bodyHash].join('\n') const signature = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex') const response = await fetch(baseURL.replace(/\/$/, '') + path, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature }, body: rawBody, }) const result = await response.json() // result.data 交给业务处理;不要将凭证、登录票据或完整观众资料写入日志。 console.log({ status: response.status, requestId: result.requestId, errorCode: result.error?.code }) ``` ### Python ```py # Python 3.9+;只使用标准库。 import hashlib, hmac, json, os, secrets, time from urllib.request import Request, build_opener, HTTPRedirectHandler from urllib.error import HTTPError class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None base_url = os.environ['OPEN_API_BASE_URL'].rstrip('/') api_key = os.environ['OPEN_API_KEY'] secret = os.environ['OPEN_API_SECRET'] path = '/api/v1/open/viewers/upsert' raw_body = "{\"externalUserId\":\"customer-10001\",\"phone\":\"13800138000\",\"displayName\":\"张三\"}".encode('utf-8') timestamp, nonce = str(int(time.time())), secrets.token_hex(16) canonical = '\n'.join([api_key, 'POST', path, timestamp, nonce, hashlib.sha256(raw_body).hexdigest()]) signature = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest() request = Request(base_url + path, data=raw_body, method='POST', headers={ 'Content-Type': 'application/json', 'X-API-Key': api_key, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, }) try: response = build_opener(NoRedirect).open(request, timeout=15) except HTTPError as error: response = error result = json.load(response) # 将 result['data'] 交给业务处理;不要记录完整响应和票据。 print({'status': response.code, 'requestId': result.get('requestId'), 'errorCode': result.get('error', {}).get('code')}) ``` ### Go ```go // Go 1.22+;只使用标准库。 package main import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "strconv" "strings" "time" ) func main() { baseURL, apiKey, secret := os.Getenv("OPEN_API_BASE_URL"), os.Getenv("OPEN_API_KEY"), os.Getenv("OPEN_API_SECRET") if baseURL == "" || apiKey == "" || secret == "" { panic("请设置环境变量") } path := "/api/v1/open/viewers/upsert" rawBody := []byte("{\"externalUserId\":\"customer-10001\",\"phone\":\"13800138000\",\"displayName\":\"张三\"}") nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { panic("随机数生成失败") } timestamp, nonce := strconv.FormatInt(time.Now().Unix(), 10), hex.EncodeToString(nonceBytes) digest := sha256.Sum256(rawBody) canonical := strings.Join([]string{apiKey, "POST", path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n") mac := hmac.New(sha256.New, []byte(secret)); mac.Write([]byte(canonical)) req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/") + path, bytes.NewReader(rawBody)) if err != nil { panic("请求构建失败") } for name, value := range map[string]string{"Content-Type":"application/json", "X-API-Key":apiKey, "X-Timestamp":timestamp, "X-Nonce":nonce, "X-Signature":hex.EncodeToString(mac.Sum(nil))} { req.Header.Set(name,value) } client := &http.Client{Timeout:15*time.Second, CheckRedirect:func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { panic("网络请求失败,请检查连接") } defer resp.Body.Close() var result map[string]any if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { panic("响应不是 JSON") } // result["data"] 交给业务处理;只记录状态和请求编号。 fmt.Printf("status=%d requestId=%v\n", resp.StatusCode, result["requestId"]) } ``` --- # 签发一次性登录票据 **POST /api/v1/open/viewers/login-tickets** 为已有观众生成直接进入活动观看页的登录链接。 ## 使用场景 你的服务端先[创建或更新观众](/api/upsert-viewer.md),再为其签发票据。把返回的 `loginUrl` 交给该用户,浏览器打开后,观看页用票据交换登录会话。 不要让最终用户持有 API Key 或 Secret;用户只需要自己的登录链接。 ## 请求参数 | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `uid` | integer | 是 | 先调用创建或更新观众取得本商户 UID,10000–9999999。 | | `ticketCode` | string | 是 | 活动公开码,从商家控制台观看链接 /w/{ticketCode} 获取;不是活动 ID。 | ## 返回值 | 字段 | 类型 | 始终返回 | 说明 | |---|---|---|---| | `requestId` | string | 是 | 请求追踪编号,排查问题时提供此值。 | | `data` | object | 是 | 业务结果。 | | `data.loginTicket` | string | 是 | 一次性登录票据,只交给目标观众;不要记录日志或共享。 | | `data.loginUrl` | string | 是 | 带 #login_ticket=… 的完整观看 URL,可直接跳转。 | | `data.expiresAt` | string | 是 | 失效时间,UTC RFC3339,例如 2026-10-12T08:01:00Z。 | ```json { "requestId": "req-example-002", "data": { "loginTicket": "example-one-time-ticket", "loginUrl": "https://watch.example.com/w/demo-live#login_ticket=example-one-time-ticket", "expiresAt": "2026-10-12T08:01:00Z" } } ``` 域名、票据和时间均为示例。直接使用 API 返回的完整 URL,可兼容商户自定义观看域名。 ## 使用规则 - `ticketCode` 是活动观看入口码,来自控制台观看链接 `/w/{ticketCode}`。UID 与活动都必须属于 API Key 对应的商户;草稿、暂停(SUSPENDED)及已隐藏活动不可用。 - 票据只能交换一次。有效期由服务端配置决定,配置示例为 60 秒,以 `expiresAt` 为准;建议在用户即将跳转时签发。 - **同一商户的同一观众重新签发票据,会使其之前的未使用票据失效**,即使两次签发指向不同活动。避免提前批量签发或并发签发。 - 票据位于 URL 的 `#login_ticket=…` 片段中,请完整传递,不要改写成查询参数。票据和完整链接应按临时登录凭证处理,不写入公开日志。 - 票据用于登录身份。活动的状态与观看规则仍以观看页实际判断为准。 ## 相关错误 | HTTP | error.code | 处理方式 | |---|---|---| | 400 | `REQUEST_INVALID` | 检查 UID 范围与活动入口码 | | 404 | `VIEWER_NOT_FOUND` | 确认 UID 来自当前商户 | | 404 | `WEBINAR_NOT_FOUND` | 检查入口码、所属商户及活动是否已发布 | | 503 | `PUBLIC_METADATA_UNAVAILABLE` | 活动入口信息暂不可用,稍后重试 | | 503 | `VIEWER_DOMAIN_UNAVAILABLE` | 观看域名或其映射暂不可用,联系部署方 | 通用错误见[错误码与排查](/guide/errors.md)。发送失败时先核对结果,避免重复签发导致前一个链接失效。 ## 请求示例 将示例 UID 和 `ticketCode` 改为当前商户的真实值。 ### cURL ```sh # 先设置 OPEN_API_BASE_URL、OPEN_API_KEY、OPEN_API_SECRET;请使用当前环境的凭证。 set -eu : "${OPEN_API_BASE_URL:?请设置 HTTPS API Origin}" : "${OPEN_API_KEY:?请设置 API Key}" : "${OPEN_API_SECRET:?请设置 Secret}" export OPEN_API_PATH='/api/v1/open/viewers/login-tickets' export OPEN_API_BODY='{"uid":10000,"ticketCode":"demo-live"}' export OPEN_API_TIMESTAMP="$(date +%s)" export OPEN_API_NONCE="$(python3 -c 'import secrets; print(secrets.token_hex(16))')" SIGNATURE="$(python3 - <<'PY' import hashlib, hmac, os body = os.environ['OPEN_API_BODY'].encode('utf-8') canonical = '\n'.join([os.environ['OPEN_API_KEY'], 'POST', os.environ['OPEN_API_PATH'], os.environ['OPEN_API_TIMESTAMP'], os.environ['OPEN_API_NONCE'], hashlib.sha256(body).hexdigest()]) print(hmac.new(os.environ['OPEN_API_SECRET'].encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()) PY )" # 不使用 -v 或调试日志;响应可能含观众资料或一次性登录票据。 curl --silent --show-error --max-time 15 \ --request POST "${OPEN_API_BASE_URL%/}${OPEN_API_PATH}" \ --header 'Content-Type: application/json' \ --header "X-API-Key: ${OPEN_API_KEY}" \ --header "X-Timestamp: ${OPEN_API_TIMESTAMP}" \ --header "X-Nonce: ${OPEN_API_NONCE}" \ --header "X-Signature: ${SIGNATURE}" \ --data-binary "${OPEN_API_BODY}" ``` ### Java ```java // Java 17+;只使用标准库。 import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Duration; import java.util.HexFormat; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; class OpenApiExample { private static String requiredEnv(String name) { String value = System.getenv(name); if (value == null || value.isBlank()) throw new IllegalArgumentException("请设置环境变量 " + name); return value; } public static void main(String[] args) throws Exception { String baseURL = requiredEnv("OPEN_API_BASE_URL").replaceFirst("/+$", ""); String apiKey = requiredEnv("OPEN_API_KEY"); String secret = requiredEnv("OPEN_API_SECRET"); String path = "/api/v1/open/viewers/login-tickets"; String rawBody = "{\"uid\":10000,\"ticketCode\":\"demo-live\"}"; byte[] bodyBytes = rawBody.getBytes(StandardCharsets.UTF_8); String timestamp = Long.toString(System.currentTimeMillis() / 1000); byte[] nonceBytes = new byte[16]; new SecureRandom().nextBytes(nonceBytes); String nonce = HexFormat.of().formatHex(nonceBytes); String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bodyBytes)); String canonical = String.join("\n", apiKey, "POST", path, timestamp, nonce, bodyHash); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String signature = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); HttpRequest request = HttpRequest.newBuilder(URI.create(baseURL + path)) .timeout(Duration.ofSeconds(15)) .header("Content-Type", "application/json") .header("X-API-Key", apiKey) .header("X-Timestamp", timestamp) .header("X-Nonce", nonce) .header("X-Signature", signature) .POST(HttpRequest.BodyPublishers.ofByteArray(bodyBytes)) .build(); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .followRedirects(HttpClient.Redirect.NEVER) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); // 仅在本地对接时查看完整响应;正式业务请用项目的 JSON 库解析,禁止记录完整观众资料或登录票据。 System.out.printf("status=%d%n%s%n", response.statusCode(), response.body()); } } ``` ### PHP ```php true, CURLOPT_POSTFIELDS => $rawBody, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-API-Key: ' . $apiKey, 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Signature: ' . $signature, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 15, ]); $responseBody = curl_exec($curl); if ($responseBody === false) { $message = curl_error($curl); curl_close($curl); throw new RuntimeException('请求失败:' . $message); } $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); $result = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); // result['data'] 交给业务处理;不要记录完整响应和票据。 printf("status=%d requestId=%s errorCode=%s\n", $status, $result['requestId'] ?? '', $result['error']['code'] ?? ''); ``` ### Node.js ```mjs // Node.js 22+;使用内置 fetch 和 crypto,无额外依赖。 import { createHash, createHmac, randomBytes } from 'node:crypto' const { OPEN_API_BASE_URL: baseURL, OPEN_API_KEY: apiKey, OPEN_API_SECRET: secret } = process.env if (!baseURL || !apiKey || !secret) throw new Error('请设置环境变量') const path = '/api/v1/open/viewers/login-tickets' const rawBody = "{\"uid\":10000,\"ticketCode\":\"demo-live\"}" const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = randomBytes(16).toString('hex') const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('hex') const canonical = [apiKey, 'POST', path, timestamp, nonce, bodyHash].join('\n') const signature = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex') const response = await fetch(baseURL.replace(/\/$/, '') + path, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature }, body: rawBody, }) const result = await response.json() // result.data 交给业务处理;不要将凭证、登录票据或完整观众资料写入日志。 console.log({ status: response.status, requestId: result.requestId, errorCode: result.error?.code }) ``` ### Python ```py # Python 3.9+;只使用标准库。 import hashlib, hmac, json, os, secrets, time from urllib.request import Request, build_opener, HTTPRedirectHandler from urllib.error import HTTPError class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None base_url = os.environ['OPEN_API_BASE_URL'].rstrip('/') api_key = os.environ['OPEN_API_KEY'] secret = os.environ['OPEN_API_SECRET'] path = '/api/v1/open/viewers/login-tickets' raw_body = "{\"uid\":10000,\"ticketCode\":\"demo-live\"}".encode('utf-8') timestamp, nonce = str(int(time.time())), secrets.token_hex(16) canonical = '\n'.join([api_key, 'POST', path, timestamp, nonce, hashlib.sha256(raw_body).hexdigest()]) signature = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest() request = Request(base_url + path, data=raw_body, method='POST', headers={ 'Content-Type': 'application/json', 'X-API-Key': api_key, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, }) try: response = build_opener(NoRedirect).open(request, timeout=15) except HTTPError as error: response = error result = json.load(response) # 将 result['data'] 交给业务处理;不要记录完整响应和票据。 print({'status': response.code, 'requestId': result.get('requestId'), 'errorCode': result.get('error', {}).get('code')}) ``` ### Go ```go // Go 1.22+;只使用标准库。 package main import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "strconv" "strings" "time" ) func main() { baseURL, apiKey, secret := os.Getenv("OPEN_API_BASE_URL"), os.Getenv("OPEN_API_KEY"), os.Getenv("OPEN_API_SECRET") if baseURL == "" || apiKey == "" || secret == "" { panic("请设置环境变量") } path := "/api/v1/open/viewers/login-tickets" rawBody := []byte("{\"uid\":10000,\"ticketCode\":\"demo-live\"}") nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { panic("随机数生成失败") } timestamp, nonce := strconv.FormatInt(time.Now().Unix(), 10), hex.EncodeToString(nonceBytes) digest := sha256.Sum256(rawBody) canonical := strings.Join([]string{apiKey, "POST", path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n") mac := hmac.New(sha256.New, []byte(secret)); mac.Write([]byte(canonical)) req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/") + path, bytes.NewReader(rawBody)) if err != nil { panic("请求构建失败") } for name, value := range map[string]string{"Content-Type":"application/json", "X-API-Key":apiKey, "X-Timestamp":timestamp, "X-Nonce":nonce, "X-Signature":hex.EncodeToString(mac.Sum(nil))} { req.Header.Set(name,value) } client := &http.Client{Timeout:15*time.Second, CheckRedirect:func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { panic("网络请求失败,请检查连接") } defer resp.Body.Close() var result map[string]any if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { panic("响应不是 JSON") } // result["data"] 交给业务处理;只记录状态和请求编号。 fmt.Printf("status=%d requestId=%v\n", resp.StatusCode, result["requestId"]) } ``` --- # 查询观看数据概览 **POST /api/v1/open/watch-data/overview** 查询活动注册/匿名观众数、会话数及有效观看秒数。 ## 使用场景 查询一个活动的总体观看情况,分别返回全部、注册和匿名观众的汇总。用于看板和数据同步;判断直播数据是否定稿时,同时检查 `dataState`。 ## 请求参数 | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `ticketCode` | string | 是 | 活动公开码,从商家控制台观看链接 /w/{ticketCode} 获取;不是活动 ID。 | | `playbackMode` | string | 是 | ALL 全部、LIVE 直播、REPLAY 回放,必须显式传入。 | ## 返回值 | 字段 | 类型 | 始终返回 | 说明 | |---|---|---|---| | `requestId` | string | 是 | 请求追踪编号,排查问题时提供此值。 | | `data` | object | 是 | 业务结果。 | | `data.ticketCode` | string | 是 | 本次查询的活动公开码。 | | `data.dataState` | string | 是 | PROVISIONAL 暂定、FINALIZING 最终化中、FINAL 达到当前模式的定稿条件。 | | `data.dataUpdatedAt` | string | 否 | 汇总最近更新时间。 UTC RFC3339;无值时省略。 | | `data.liveDataUpdatedAt` | string | 否 | 直播数据最近更新时间。 UTC RFC3339;无值时省略。 | | `data.replayDataUpdatedAt` | string | 否 | 回放数据最近更新时间。 UTC RFC3339;无值时省略。 | | `data.all` | object | 是 | 全部主体统计。 | | `data.all.viewerCount` | integer | 是 | 观看主体数,ALL 暂定数据的计数限制见统计口径。 | | `data.all.sessionCount` | integer | 是 | 该范围播放会话数,不等于观众数。 | | `data.all.watchSeconds` | integer | 是 | 该范围累计有效观看秒数。 | | `data.registered` | object | 是 | 注册主体统计。 | | `data.registered.viewerCount` | integer | 是 | 观看主体数,ALL 暂定数据的计数限制见统计口径。 | | `data.registered.sessionCount` | integer | 是 | 该范围播放会话数,不等于观众数。 | | `data.registered.watchSeconds` | integer | 是 | 该范围累计有效观看秒数。 | | `data.anonymous` | object | 是 | 匿名主体统计。 | | `data.anonymous.viewerCount` | integer | 是 | 观看主体数,ALL 暂定数据的计数限制见统计口径。 | | `data.anonymous.sessionCount` | integer | 是 | 该范围播放会话数,不等于观众数。 | | `data.anonymous.watchSeconds` | integer | 是 | 该范围累计有效观看秒数。 | | `data.liveWatchSeconds` | integer | 是 | 直播有效观看秒数,受 playbackMode 筛选。 | | `data.replayWatchSeconds` | integer | 是 | 回放有效观看秒数,受 playbackMode 筛选。 | ```json { "requestId": "req-example-003", "data": { "ticketCode": "demo-live", "dataState": "PROVISIONAL", "dataUpdatedAt": "2026-10-12T08:00:00Z", "liveDataUpdatedAt": "2026-10-12T07:50:00Z", "replayDataUpdatedAt": "2026-10-12T08:00:00Z", "all": { "viewerCount": 12, "sessionCount": 20, "watchSeconds": 7200 }, "registered": { "viewerCount": 10, "sessionCount": 17, "watchSeconds": 6600 }, "anonymous": { "viewerCount": 2, "sessionCount": 3, "watchSeconds": 600 }, "liveWatchSeconds": 6000, "replayWatchSeconds": 1200 } } ``` ## 统计口径 | 概念 | 说明 | |---|---| | `ALL`/`LIVE`/`REPLAY` | 分别统计全部/直播/回放;必须显式指定 | | 注册观众 | 已识别的观众账户;匿名观众按系统匿名主体统计,不等同于自然人 | | `viewerCount` | 范围内观看主体数;暂定的 ALL 概览可能将同一主体的直播与回放计数相加,不能直接当作跨模式去重人数 | | `sessionCount` | 播放会话数,同一观众可能有多次会话 | | `watchSeconds` | 服务端认定的有效观看秒数,不是页面打开时长,也不是活动持续时长 | | 最近更新时间 | 数据处理时间;尚无对应时间时字段省略,不返回空字符串 | 直播未定稿时可能读取实时聚合,回放随观看持续更新。概览允许返回暂定数据;它不会因为直播明细未定稿就必然返回 `WATCH_DATA_NOT_FINAL`。 ## 数据状态 | dataState | 如何使用 | |---|---| | `PROVISIONAL` | 当前仍是暂定数据,后续可能更新;REPLAY 查询始终按暂定数据处理,ALL 含回放数据时也如此 | | `FINALIZING` | LIVE,或尚无回放数据的 ALL,活动已结束/取消/失败,直播汇总正在最终化 | | `FINAL` | 已达到当前查询模式的直播定稿条件;不能据此断言回放数据永久不变 | 需要结算直播数据时,使用 `LIVE` 并等待 `FINAL`。本接口返回概览,不提供所有观众的逐条记录;注册观众明细使用[观看明细接口](/api/watch-viewers.md)。 ## 相关错误 | HTTP | error.code | 处理方式 | |---|---|---| | 400 | `REQUEST_INVALID` | 检查是否显式传入合法 playbackMode | | 404 | `WEBINAR_NOT_FOUND` | 检查入口码与所属商户 | | 503 | `WATCH_DATA_UNAVAILABLE` | 汇总数据暂不可用,稍后查询 | 通用错误见[错误码与排查](/guide/errors.md)。 ## 请求示例 ### cURL ```sh # 先设置 OPEN_API_BASE_URL、OPEN_API_KEY、OPEN_API_SECRET;请使用当前环境的凭证。 set -eu : "${OPEN_API_BASE_URL:?请设置 HTTPS API Origin}" : "${OPEN_API_KEY:?请设置 API Key}" : "${OPEN_API_SECRET:?请设置 Secret}" export OPEN_API_PATH='/api/v1/open/watch-data/overview' export OPEN_API_BODY='{"ticketCode":"demo-live","playbackMode":"ALL"}' export OPEN_API_TIMESTAMP="$(date +%s)" export OPEN_API_NONCE="$(python3 -c 'import secrets; print(secrets.token_hex(16))')" SIGNATURE="$(python3 - <<'PY' import hashlib, hmac, os body = os.environ['OPEN_API_BODY'].encode('utf-8') canonical = '\n'.join([os.environ['OPEN_API_KEY'], 'POST', os.environ['OPEN_API_PATH'], os.environ['OPEN_API_TIMESTAMP'], os.environ['OPEN_API_NONCE'], hashlib.sha256(body).hexdigest()]) print(hmac.new(os.environ['OPEN_API_SECRET'].encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()) PY )" # 不使用 -v 或调试日志;响应可能含观众资料或一次性登录票据。 curl --silent --show-error --max-time 15 \ --request POST "${OPEN_API_BASE_URL%/}${OPEN_API_PATH}" \ --header 'Content-Type: application/json' \ --header "X-API-Key: ${OPEN_API_KEY}" \ --header "X-Timestamp: ${OPEN_API_TIMESTAMP}" \ --header "X-Nonce: ${OPEN_API_NONCE}" \ --header "X-Signature: ${SIGNATURE}" \ --data-binary "${OPEN_API_BODY}" ``` ### Java ```java // Java 17+;只使用标准库。 import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Duration; import java.util.HexFormat; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; class OpenApiExample { private static String requiredEnv(String name) { String value = System.getenv(name); if (value == null || value.isBlank()) throw new IllegalArgumentException("请设置环境变量 " + name); return value; } public static void main(String[] args) throws Exception { String baseURL = requiredEnv("OPEN_API_BASE_URL").replaceFirst("/+$", ""); String apiKey = requiredEnv("OPEN_API_KEY"); String secret = requiredEnv("OPEN_API_SECRET"); String path = "/api/v1/open/watch-data/overview"; String rawBody = "{\"ticketCode\":\"demo-live\",\"playbackMode\":\"ALL\"}"; byte[] bodyBytes = rawBody.getBytes(StandardCharsets.UTF_8); String timestamp = Long.toString(System.currentTimeMillis() / 1000); byte[] nonceBytes = new byte[16]; new SecureRandom().nextBytes(nonceBytes); String nonce = HexFormat.of().formatHex(nonceBytes); String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bodyBytes)); String canonical = String.join("\n", apiKey, "POST", path, timestamp, nonce, bodyHash); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String signature = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); HttpRequest request = HttpRequest.newBuilder(URI.create(baseURL + path)) .timeout(Duration.ofSeconds(15)) .header("Content-Type", "application/json") .header("X-API-Key", apiKey) .header("X-Timestamp", timestamp) .header("X-Nonce", nonce) .header("X-Signature", signature) .POST(HttpRequest.BodyPublishers.ofByteArray(bodyBytes)) .build(); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .followRedirects(HttpClient.Redirect.NEVER) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); // 仅在本地对接时查看完整响应;正式业务请用项目的 JSON 库解析,禁止记录完整观众资料或登录票据。 System.out.printf("status=%d%n%s%n", response.statusCode(), response.body()); } } ``` ### PHP ```php true, CURLOPT_POSTFIELDS => $rawBody, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-API-Key: ' . $apiKey, 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Signature: ' . $signature, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 15, ]); $responseBody = curl_exec($curl); if ($responseBody === false) { $message = curl_error($curl); curl_close($curl); throw new RuntimeException('请求失败:' . $message); } $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); $result = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); // result['data'] 交给业务处理;不要记录完整响应和票据。 printf("status=%d requestId=%s errorCode=%s\n", $status, $result['requestId'] ?? '', $result['error']['code'] ?? ''); ``` ### Node.js ```mjs // Node.js 22+;使用内置 fetch 和 crypto,无额外依赖。 import { createHash, createHmac, randomBytes } from 'node:crypto' const { OPEN_API_BASE_URL: baseURL, OPEN_API_KEY: apiKey, OPEN_API_SECRET: secret } = process.env if (!baseURL || !apiKey || !secret) throw new Error('请设置环境变量') const path = '/api/v1/open/watch-data/overview' const rawBody = "{\"ticketCode\":\"demo-live\",\"playbackMode\":\"ALL\"}" const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = randomBytes(16).toString('hex') const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('hex') const canonical = [apiKey, 'POST', path, timestamp, nonce, bodyHash].join('\n') const signature = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex') const response = await fetch(baseURL.replace(/\/$/, '') + path, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature }, body: rawBody, }) const result = await response.json() // result.data 交给业务处理;不要将凭证、登录票据或完整观众资料写入日志。 console.log({ status: response.status, requestId: result.requestId, errorCode: result.error?.code }) ``` ### Python ```py # Python 3.9+;只使用标准库。 import hashlib, hmac, json, os, secrets, time from urllib.request import Request, build_opener, HTTPRedirectHandler from urllib.error import HTTPError class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None base_url = os.environ['OPEN_API_BASE_URL'].rstrip('/') api_key = os.environ['OPEN_API_KEY'] secret = os.environ['OPEN_API_SECRET'] path = '/api/v1/open/watch-data/overview' raw_body = "{\"ticketCode\":\"demo-live\",\"playbackMode\":\"ALL\"}".encode('utf-8') timestamp, nonce = str(int(time.time())), secrets.token_hex(16) canonical = '\n'.join([api_key, 'POST', path, timestamp, nonce, hashlib.sha256(raw_body).hexdigest()]) signature = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest() request = Request(base_url + path, data=raw_body, method='POST', headers={ 'Content-Type': 'application/json', 'X-API-Key': api_key, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, }) try: response = build_opener(NoRedirect).open(request, timeout=15) except HTTPError as error: response = error result = json.load(response) # 将 result['data'] 交给业务处理;不要记录完整响应和票据。 print({'status': response.code, 'requestId': result.get('requestId'), 'errorCode': result.get('error', {}).get('code')}) ``` ### Go ```go // Go 1.22+;只使用标准库。 package main import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "strconv" "strings" "time" ) func main() { baseURL, apiKey, secret := os.Getenv("OPEN_API_BASE_URL"), os.Getenv("OPEN_API_KEY"), os.Getenv("OPEN_API_SECRET") if baseURL == "" || apiKey == "" || secret == "" { panic("请设置环境变量") } path := "/api/v1/open/watch-data/overview" rawBody := []byte("{\"ticketCode\":\"demo-live\",\"playbackMode\":\"ALL\"}") nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { panic("随机数生成失败") } timestamp, nonce := strconv.FormatInt(time.Now().Unix(), 10), hex.EncodeToString(nonceBytes) digest := sha256.Sum256(rawBody) canonical := strings.Join([]string{apiKey, "POST", path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n") mac := hmac.New(sha256.New, []byte(secret)); mac.Write([]byte(canonical)) req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/") + path, bytes.NewReader(rawBody)) if err != nil { panic("请求构建失败") } for name, value := range map[string]string{"Content-Type":"application/json", "X-API-Key":apiKey, "X-Timestamp":timestamp, "X-Nonce":nonce, "X-Signature":hex.EncodeToString(mac.Sum(nil))} { req.Header.Set(name,value) } client := &http.Client{Timeout:15*time.Second, CheckRedirect:func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { panic("网络请求失败,请检查连接") } defer resp.Body.Close() var result map[string]any if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { panic("响应不是 JSON") } // result["data"] 交给业务处理;只记录状态和请求编号。 fmt.Printf("status=%d requestId=%v\n", resp.StatusCode, result["requestId"]) } ``` --- # 查询观众观看明细 **POST /api/v1/open/watch-data/viewers/query** 按 UID 或外部标识查询注册观众有效观看时长,使用游标分页。 ## 使用场景 按 UID 或外部标识获取注册观众的有效观看时长,例如同步培训完成情况。只返回有观看记录且符合筛选条件的注册观众,不包含匿名主体,也不返回观众总数。 ## 请求参数 | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `ticketCode` | string | 是 | 活动公开码,从商家控制台观看链接 /w/{ticketCode} 获取;不是活动 ID。 | | `playbackMode` | string | 是 | ALL 全部、LIVE 直播、REPLAY 回放,必须显式传入。 | | `uids` | integer[] | 否 | 每项 10000–9999999;与 externalUserIds 按 OR 匹配,合计最多 300 项。 | | `externalUserIds` | string[] | 否 | 每项最多 191 个字符;与 uids 合计最多 300 项。 | | `minWatchSeconds` | integer | 否 | 最低有效观看秒数,0 或省略表示不设下限;建议显式传 0。 | | `sort` | string | 是 | WATCH_SECONDS_DESC 时长降序;LAST_WATCHED_DESC 最近观看降序;FIRST_WATCHED_ASC 首次观看升序。必须显式传入。 | | `cursor` | string | 否 | 首次省略;续页原样传上次 nextCursor,最多 1024 字符。 | | `pageSize` | integer | 是 | 每页 1–100 条,必须显式传入。 | `playbackMode`、`sort`、`pageSize` 必须显式传入;当前 HTTP 解析不会应用接口定义中的 default 标签。`minWatchSeconds` 可以省略,建议传 `0` 使意图清楚。 ## 返回值 | 字段 | 类型 | 始终返回 | 说明 | |---|---|---|---| | `requestId` | string | 是 | 请求追踪编号,排查问题时提供此值。 | | `data` | object | 是 | 业务结果。 | | `data.items` | object[] | 是 | 本页注册观众,无匹配时为空数组。 | | `data.nextCursor` | string | 否 | 下一页游标;字段省略时分页结束。 | | `data.items[].subjectId` | string | 是 | 不透明的观看主体标识;没有对应 Open API 详情接口,不要解码。 | | `data.items[].identityType` | string | 是 | 仅返回 REGISTERED。 | | `data.items[].displayNickname` | string | 是 | 系统计算的昵称,可能使用手机号尾号或稳定码。 | | `data.items[].uid` | integer | 是 | 商户内观众 UID。 | | `data.items[].externalUserId` | string | 否 | 外部用户标识,未绑定省略。 | | `data.items[].phone` | string | 否 | 绑定手机号,未绑定省略。 | | `data.items[].displayName` | string | 否 | 设置的昵称,未设置省略。 | | `data.items[].totalWatchSeconds` | integer | 是 | 筛选范围内累计有效观看秒数。 | | `data.items[].liveWatchSeconds` | integer | 是 | 直播有效观看秒数。 | | `data.items[].replayWatchSeconds` | integer | 是 | 回放有效观看秒数。 | | `data.items[].sessionCount` | integer | 是 | 筛选范围播放会话数。 | | `data.items[].firstWatchedAt` | string | 是 | 首次观看时间,UTC RFC3339。 | | `data.items[].lastWatchedAt` | string | 是 | 最近观看时间,UTC RFC3339。 | | `data.items[].deviceTypes` | string[] | 是 | 设备分类,例如 DESKTOP、MOBILE、TABLET、TV、UNKNOWN。 | | `data.items[].protocols` | string[] | 是 | 协议,例如 WEBRTC、HLS,以实际记录为准。 | ```json { "requestId": "req-example-004", "data": { "items": [ { "subjectId": "example-opaque-subject", "identityType": "REGISTERED", "displayNickname": "张三", "uid": 10000, "externalUserId": "customer-10001", "displayName": "张三", "totalWatchSeconds": 600, "liveWatchSeconds": 0, "replayWatchSeconds": 600, "sessionCount": 1, "firstWatchedAt": "2026-10-12T08:00:00Z", "lastWatchedAt": "2026-10-12T08:10:00Z", "deviceTypes": ["DESKTOP"], "protocols": ["HLS"] } ] } } ``` 示例只有一页,故没有 `nextCursor`。无匹配记录时返回 `items: []`。昵称、手机号、外部标识的省略规则与[观众写入接口](/api/upsert-viewer.md)中的 `null` 规则不同。 ## 筛选与分页 1. 首次请求不传 `cursor`。不传 UID 和外部标识时,查询当前活动符合条件的注册观众。 2. 同时提供 `uids` 和 `externalUserIds` 时按 **OR** 匹配。两个数组合计最多 300 项,重复项也占输入数量;可分批查询,调用方自行合并结果。 3. 接口按有效观看秒数筛选和排序。相同排序值由服务端继续按稳定字段排序,请勿自行构造游标。 4. 有 `nextCursor` 时原样作为下次请求的 `cursor`。保持活动、播放模式、筛选条件和排序不变;修改这些条件后移除 cursor,从第一页重新查询。 5. `nextCursor` 字段省略表示结束。接口无 `total`、无页码,也不是数据快照;回放数据持续变化时,不保证跨页得到某一时刻的完整快照。 `subjectId` 为不透明标识;当前 Open API 没有对应的主体详情或会话详情接口,请不要调用商家后台接口补充查询。 ## 数据可用时间 - `LIVE` 与 `ALL`:活动开始后,直播数据未定稿时返回 HTTP 409 `WATCH_DATA_NOT_FINAL`。稍后用新 nonce 重新查询;可结合 LIVE 概览的状态判断。 - `REPLAY`:不受直播定稿门槛限制,但数据仍可能随回放观看更新。 - 时长单位为秒,以 `totalWatchSeconds` 判断本次筛选范围内的观看时长;不要按会话数推算时长。 ## 相关错误 | HTTP | error.code | 处理方式 | |---|---|---| | 400 | `REQUEST_INVALID` | 检查必填字段、枚举、UID、pageSize | | 400 | `WATCH_REQUEST_INVALID` | 检查数组合计数量、游标长度及游标与筛选条件是否匹配 | | 404 | `WEBINAR_NOT_FOUND` | 检查入口码与所属商户 | | 409 | `WATCH_DATA_NOT_FINAL` | 等待直播数据最终化,再用新 nonce 查询 | | 503 | `WATCH_DATA_UNAVAILABLE` | 观看数据暂不可用,稍后查询 | 通用错误见[错误码与排查](/guide/errors.md)。 ## 请求示例 示例使用 REPLAY 模式,可避开直播数据未定稿时的查询门槛。改为 LIVE 时请先确认数据已定稿。 ### cURL ```sh # 先设置 OPEN_API_BASE_URL、OPEN_API_KEY、OPEN_API_SECRET;请使用当前环境的凭证。 set -eu : "${OPEN_API_BASE_URL:?请设置 HTTPS API Origin}" : "${OPEN_API_KEY:?请设置 API Key}" : "${OPEN_API_SECRET:?请设置 Secret}" export OPEN_API_PATH='/api/v1/open/watch-data/viewers/query' export OPEN_API_BODY='{"ticketCode":"demo-live","playbackMode":"REPLAY","uids":[10000],"minWatchSeconds":0,"sort":"WATCH_SECONDS_DESC","pageSize":100}' export OPEN_API_TIMESTAMP="$(date +%s)" export OPEN_API_NONCE="$(python3 -c 'import secrets; print(secrets.token_hex(16))')" SIGNATURE="$(python3 - <<'PY' import hashlib, hmac, os body = os.environ['OPEN_API_BODY'].encode('utf-8') canonical = '\n'.join([os.environ['OPEN_API_KEY'], 'POST', os.environ['OPEN_API_PATH'], os.environ['OPEN_API_TIMESTAMP'], os.environ['OPEN_API_NONCE'], hashlib.sha256(body).hexdigest()]) print(hmac.new(os.environ['OPEN_API_SECRET'].encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()) PY )" # 不使用 -v 或调试日志;响应可能含观众资料或一次性登录票据。 curl --silent --show-error --max-time 15 \ --request POST "${OPEN_API_BASE_URL%/}${OPEN_API_PATH}" \ --header 'Content-Type: application/json' \ --header "X-API-Key: ${OPEN_API_KEY}" \ --header "X-Timestamp: ${OPEN_API_TIMESTAMP}" \ --header "X-Nonce: ${OPEN_API_NONCE}" \ --header "X-Signature: ${SIGNATURE}" \ --data-binary "${OPEN_API_BODY}" ``` ### Java ```java // Java 17+;只使用标准库。 import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Duration; import java.util.HexFormat; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; class OpenApiExample { private static String requiredEnv(String name) { String value = System.getenv(name); if (value == null || value.isBlank()) throw new IllegalArgumentException("请设置环境变量 " + name); return value; } public static void main(String[] args) throws Exception { String baseURL = requiredEnv("OPEN_API_BASE_URL").replaceFirst("/+$", ""); String apiKey = requiredEnv("OPEN_API_KEY"); String secret = requiredEnv("OPEN_API_SECRET"); String path = "/api/v1/open/watch-data/viewers/query"; String rawBody = "{\"ticketCode\":\"demo-live\",\"playbackMode\":\"REPLAY\",\"uids\":[10000],\"minWatchSeconds\":0,\"sort\":\"WATCH_SECONDS_DESC\",\"pageSize\":100}"; byte[] bodyBytes = rawBody.getBytes(StandardCharsets.UTF_8); String timestamp = Long.toString(System.currentTimeMillis() / 1000); byte[] nonceBytes = new byte[16]; new SecureRandom().nextBytes(nonceBytes); String nonce = HexFormat.of().formatHex(nonceBytes); String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bodyBytes)); String canonical = String.join("\n", apiKey, "POST", path, timestamp, nonce, bodyHash); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); String signature = HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8))); HttpRequest request = HttpRequest.newBuilder(URI.create(baseURL + path)) .timeout(Duration.ofSeconds(15)) .header("Content-Type", "application/json") .header("X-API-Key", apiKey) .header("X-Timestamp", timestamp) .header("X-Nonce", nonce) .header("X-Signature", signature) .POST(HttpRequest.BodyPublishers.ofByteArray(bodyBytes)) .build(); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .followRedirects(HttpClient.Redirect.NEVER) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); // 仅在本地对接时查看完整响应;正式业务请用项目的 JSON 库解析,禁止记录完整观众资料或登录票据。 System.out.printf("status=%d%n%s%n", response.statusCode(), response.body()); } } ``` ### PHP ```php true, CURLOPT_POSTFIELDS => $rawBody, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-API-Key: ' . $apiKey, 'X-Timestamp: ' . $timestamp, 'X-Nonce: ' . $nonce, 'X-Signature: ' . $signature, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 15, ]); $responseBody = curl_exec($curl); if ($responseBody === false) { $message = curl_error($curl); curl_close($curl); throw new RuntimeException('请求失败:' . $message); } $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); curl_close($curl); $result = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); // result['data'] 交给业务处理;不要记录完整响应和票据。 printf("status=%d requestId=%s errorCode=%s\n", $status, $result['requestId'] ?? '', $result['error']['code'] ?? ''); ``` ### Node.js ```mjs // Node.js 22+;使用内置 fetch 和 crypto,无额外依赖。 import { createHash, createHmac, randomBytes } from 'node:crypto' const { OPEN_API_BASE_URL: baseURL, OPEN_API_KEY: apiKey, OPEN_API_SECRET: secret } = process.env if (!baseURL || !apiKey || !secret) throw new Error('请设置环境变量') const path = '/api/v1/open/watch-data/viewers/query' const rawBody = "{\"ticketCode\":\"demo-live\",\"playbackMode\":\"REPLAY\",\"uids\":[10000],\"minWatchSeconds\":0,\"sort\":\"WATCH_SECONDS_DESC\",\"pageSize\":100}" const timestamp = String(Math.floor(Date.now() / 1000)) const nonce = randomBytes(16).toString('hex') const bodyHash = createHash('sha256').update(rawBody, 'utf8').digest('hex') const canonical = [apiKey, 'POST', path, timestamp, nonce, bodyHash].join('\n') const signature = createHmac('sha256', secret).update(canonical, 'utf8').digest('hex') const response = await fetch(baseURL.replace(/\/$/, '') + path, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature }, body: rawBody, }) const result = await response.json() // result.data 交给业务处理;不要将凭证、登录票据或完整观众资料写入日志。 console.log({ status: response.status, requestId: result.requestId, errorCode: result.error?.code }) ``` ### Python ```py # Python 3.9+;只使用标准库。 import hashlib, hmac, json, os, secrets, time from urllib.request import Request, build_opener, HTTPRedirectHandler from urllib.error import HTTPError class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None base_url = os.environ['OPEN_API_BASE_URL'].rstrip('/') api_key = os.environ['OPEN_API_KEY'] secret = os.environ['OPEN_API_SECRET'] path = '/api/v1/open/watch-data/viewers/query' raw_body = "{\"ticketCode\":\"demo-live\",\"playbackMode\":\"REPLAY\",\"uids\":[10000],\"minWatchSeconds\":0,\"sort\":\"WATCH_SECONDS_DESC\",\"pageSize\":100}".encode('utf-8') timestamp, nonce = str(int(time.time())), secrets.token_hex(16) canonical = '\n'.join([api_key, 'POST', path, timestamp, nonce, hashlib.sha256(raw_body).hexdigest()]) signature = hmac.new(secret.encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest() request = Request(base_url + path, data=raw_body, method='POST', headers={ 'Content-Type': 'application/json', 'X-API-Key': api_key, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, }) try: response = build_opener(NoRedirect).open(request, timeout=15) except HTTPError as error: response = error result = json.load(response) # 将 result['data'] 交给业务处理;不要记录完整响应和票据。 print({'status': response.code, 'requestId': result.get('requestId'), 'errorCode': result.get('error', {}).get('code')}) ``` ### Go ```go // Go 1.22+;只使用标准库。 package main import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "strconv" "strings" "time" ) func main() { baseURL, apiKey, secret := os.Getenv("OPEN_API_BASE_URL"), os.Getenv("OPEN_API_KEY"), os.Getenv("OPEN_API_SECRET") if baseURL == "" || apiKey == "" || secret == "" { panic("请设置环境变量") } path := "/api/v1/open/watch-data/viewers/query" rawBody := []byte("{\"ticketCode\":\"demo-live\",\"playbackMode\":\"REPLAY\",\"uids\":[10000],\"minWatchSeconds\":0,\"sort\":\"WATCH_SECONDS_DESC\",\"pageSize\":100}") nonceBytes := make([]byte, 16) if _, err := rand.Read(nonceBytes); err != nil { panic("随机数生成失败") } timestamp, nonce := strconv.FormatInt(time.Now().Unix(), 10), hex.EncodeToString(nonceBytes) digest := sha256.Sum256(rawBody) canonical := strings.Join([]string{apiKey, "POST", path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n") mac := hmac.New(sha256.New, []byte(secret)); mac.Write([]byte(canonical)) req, err := http.NewRequest("POST", strings.TrimRight(baseURL, "/") + path, bytes.NewReader(rawBody)) if err != nil { panic("请求构建失败") } for name, value := range map[string]string{"Content-Type":"application/json", "X-API-Key":apiKey, "X-Timestamp":timestamp, "X-Nonce":nonce, "X-Signature":hex.EncodeToString(mac.Sum(nil))} { req.Header.Set(name,value) } client := &http.Client{Timeout:15*time.Second, CheckRedirect:func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { panic("网络请求失败,请检查连接") } defer resp.Body.Close() var result map[string]any if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { panic("响应不是 JSON") } // result["data"] 交给业务处理;只记录状态和请求编号。 fmt.Printf("status=%d requestId=%v\n", resp.StatusCode, result["requestId"]) } ```