# 查询观众观看明细 **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"]) } ```