创建或更新外部观众
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 分配空间已耗尽,联系平台 |
请求示例
先设置当前环境的 OPEN_API_BASE_URL、OPEN_API_KEY、OPEN_API_SECRET,示例会自动签名。不要把真实 Secret 写入代码仓库。
运行前设置 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/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 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<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/viewers/upsert';
$rawBody = '{"externalUserId":"customer-10001","phone":"13800138000","displayName":"张三"}';
$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/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
# 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 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"])
}