Skip to content

查询观众观看明细 ​

POST/api/v1/open/watch-data/viewers/query

按 UID 或外部标识查询注册观众有效观看时长,使用游标分页。

使用场景 ​

按 UID 或外部标识获取注册观众的有效观看时长,例如同步培训完成情况。只返回有观看记录且符合筛选条件的注册观众,不包含匿名主体,也不返回观众总数。

请求参数 ​

字段类型必填说明
ticketCodestring是活动公开码,从商家控制台观看链接 /w/{ticketCode} 获取;不是活动 ID。
playbackModestring是ALL 全部、LIVE 直播、REPLAY 回放,必须显式传入。
uidsinteger[]否每项 10000–9999999;与 externalUserIds 按 OR 匹配,合计最多 300 项。
externalUserIdsstring[]否每项最多 191 个字符;与 uids 合计最多 300 项。
minWatchSecondsinteger否最低有效观看秒数,0 或省略表示不设下限;建议显式传 0。
sortstring是WATCH_SECONDS_DESC 时长降序;LAST_WATCHED_DESC 最近观看降序;FIRST_WATCHED_ASC 首次观看升序。必须显式传入。
cursorstring否首次省略;续页原样传上次 nextCursor,最多 1024 字符。
pageSizeinteger是每页 1–100 条,必须显式传入。

playbackMode、sort、pageSize 必须显式传入;当前 HTTP 解析不会应用接口定义中的 default 标签。minWatchSeconds 可以省略,建议传 0 使意图清楚。

返回值 ​

字段类型始终返回说明
requestIdstring是请求追踪编号,排查问题时提供此值。
dataobject是业务结果。
data.itemsobject[]是本页注册观众,无匹配时为空数组。
data.nextCursorstring否下一页游标;字段省略时分页结束。
data.items[].subjectIdstring是不透明的观看主体标识;没有对应 Open API 详情接口,不要解码。
data.items[].identityTypestring是仅返回 REGISTERED。
data.items[].displayNicknamestring是系统计算的昵称,可能使用手机号尾号或稳定码。
data.items[].uidinteger是商户内观众 UID。
data.items[].externalUserIdstring否外部用户标识,未绑定省略。
data.items[].phonestring否绑定手机号,未绑定省略。
data.items[].displayNamestring否设置的昵称,未设置省略。
data.items[].totalWatchSecondsinteger是筛选范围内累计有效观看秒数。
data.items[].liveWatchSecondsinteger是直播有效观看秒数。
data.items[].replayWatchSecondsinteger是回放有效观看秒数。
data.items[].sessionCountinteger是筛选范围播放会话数。
data.items[].firstWatchedAtstring是首次观看时间,UTC RFC3339。
data.items[].lastWatchedAtstring是最近观看时间,UTC RFC3339。
data.items[].deviceTypesstring[]是设备分类,例如 DESKTOP、MOBILE、TABLET、TV、UNKNOWN。
data.items[].protocolsstring[]是协议,例如 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: []。昵称、手机号、外部标识的省略规则与观众写入接口中的 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 判断本次筛选范围内的观看时长;不要按会话数推算时长。

相关错误 ​

HTTPerror.code处理方式
400REQUEST_INVALID检查必填字段、枚举、UID、pageSize
400WATCH_REQUEST_INVALID检查数组合计数量、游标长度及游标与筛选条件是否匹配
404WEBINAR_NOT_FOUND检查入口码与所属商户
409WATCH_DATA_NOT_FINAL等待直播数据最终化,再用新 nonce 查询
503WATCH_DATA_UNAVAILABLE观看数据暂不可用,稍后查询

通用错误见错误码与排查。

请求示例 ​

示例使用 REPLAY 模式,可避开直播数据未定稿时的查询门槛。改为 LIVE 时请先确认数据已定稿。

运行前设置 OPEN_API_BASE_URL、OPEN_API_KEY 和 OPEN_API_SECRET 环境变量。示例只发一次请求,不自动重试。

cURL
下载示例
# 先设置 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 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<String> 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
// PHP 8.1+;需要 cURL 扩展,不依赖第三方包。
function required_env(string $name): string {
    $value = getenv($name);
    if ($value === false || $value === '') {
        throw new RuntimeException('请设置环境变量 ' . $name);
    }
    return $value;
}

$baseURL = rtrim(required_env('OPEN_API_BASE_URL'), '/');
$apiKey = required_env('OPEN_API_KEY');
$secret = required_env('OPEN_API_SECRET');
$path = '/api/v1/open/watch-data/viewers/query';
$rawBody = '{"ticketCode":"demo-live","playbackMode":"REPLAY","uids":[10000],"minWatchSeconds":0,"sort":"WATCH_SECONDS_DESC","pageSize":100}';
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$bodyHash = hash('sha256', $rawBody);
$canonical = implode("\n", [$apiKey, 'POST', $path, $timestamp, $nonce, $bodyHash]);
$signature = hash_hmac('sha256', $canonical, $secret);

$curl = curl_init($baseURL . $path);
if ($curl === false) {
    throw new RuntimeException('无法初始化 cURL');
}
curl_setopt_array($curl, [
    CURLOPT_POST => 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
下载示例
// 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
下载示例
# 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 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"])
}