预约手机号授权
预约手机号授权用于在用户授权或取消授权预约手机号时,将事件异步推送到游戏服务端。游戏服务端完成验签后,可以保存授权关系,并在授权事件中解密得到用户手机号。
请先阅读接口公共说明了解接口签名与验签规则。
配置回调地址
在 TapTap 开发者中心 > 游戏服务 > 运营工具 > 预约手机号授权 中配置回调地址并开启服务。
回调接口
该接口用于接收用户预约手机号授权状态变更事件。TapTap 在用户授权或取消授权预约手机号时,向配置的回调地址发起 POST 请求。
注意
- URL:
https://xxx/reserve/callback(仅供参考,开发者在后台配置的回调地址) - Method:
POST - Content-Type:
application/json; charset=utf-8 - 需要验签:是
请求参数
注意
公共参数见 接口公共说明 文档
Body
| 字段 | 类型 | 说明 |
|---|---|---|
event_id | string | 事件唯一 ID。建议作为幂等键,避免重复处理同一事件。 |
event_type | string | 事件类型。authorize 表示用户授权,cancel 表示用户取消授权,test 表示测试推送。 |
client_id | string | TapTap 开放平台 Client ID。 |
openid | string | 用户在当前 Client ID 下的 OpenID。 |
unionid | string | 用户在当前开发者账号下的 UnionID。 |
reserve_type | string | 预约类型。可选值:android、pc。 |
encrypted_phone | string | 加密后的手机号,仅在 authorize 事件中返回。 |
time | number | 事件发生时间,秒级时间戳。 |
请求参数示例
用户授权
用户授权后,回调请求体中会包含 encrypted_phone。游戏服务端验签通过后,可以使用开放平台 Server Secret 解密手机号。
{
"event_id": "018fd2aa-7b8c-7b21-9c83-2f36f53fb350",
"event_type": "authorize",
"client_id": "tap-client-id",
"openid": "openid-for-this-client",
"unionid": "unionid-for-this-client",
"reserve_type": "android",
"encrypted_phone": "base64url(nonce || ciphertext || tag)",
"time": 1770000000
}
用户取消授权
用户取消授权后,回调请求体不包含 encrypted_phone。游戏服务端可以根据 openid、unionid 和 reserve_type 更新授权状态。
{
"event_id": "018fd2aa-7b8c-7b21-9c83-2f36f53fb351",
"event_type": "cancel",
"client_id": "tap-client-id",
"openid": "openid-for-this-client",
"unionid": "unionid-for-this-client",
"reserve_type": "android",
"time": 1770000100
}
成功响应
注意
- 状态码:200 状态码
- 响应示例:无固定响应体要求
错误响应
接口返回非 200 状态码时,TapTap 会视为本次推送未成功,然后重试。重试间隔依次为 60 秒、5 分钟、30 分钟、2 小时、6 小时,之后每 24 小时重试一次,最多重试 8 次。
处理建议
- 验签通过后再解析和处理请求体。
- 使用
event_id做幂等处理。 event_type为authorize时保存授权关系和手机号,cancel时清理或标记取消授权。- 服务端处理成功后返回
200状态码。 event_type为test时,请只用于联调验证,不要写入正式业务。
手机号解密算法
算法说明
encrypted_phone 使用不带 padding 的 Base64url 编码(Base64url without padding)。
编码前的原始字节由 nonce、ciphertext 和 tag 依次拼接:
encrypted_phone = base64url_without_padding(nonce || ciphertext || tag)
服务端解密时,先对 encrypted_phone 做 Base64url without padding 解码,再按以下规则拆分原始字节:
nonce:前 12 字节ciphertext:中间部分tag:最后 16 字节
使用 AES-256-GCM 解密,密钥为 TapTap 开放平台发放的 Server Secret 原始字符串的 UTF-8 字节。请勿对 Server Secret 做额外转换。
phone = AES-256-GCM-Decrypt(
key = UTF8(Server Secret),
nonce = nonce,
ciphertext = ciphertext,
tag = tag,
aad = empty
)
示例代码
Node.js 解密示例
const crypto = require("crypto");
function decodeBase64UrlNoPadding(value) {
if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
throw new Error("invalid encrypted_phone");
}
return Buffer.from(value, "base64url");
}
function decryptReservePhone(encryptedPhone, serverSecret) {
const data = decodeBase64UrlNoPadding(encryptedPhone);
if (data.length <= 28) {
throw new Error("invalid encrypted_phone");
}
const nonce = data.subarray(0, 12);
const ciphertext = data.subarray(12, data.length - 16);
const tag = data.subarray(data.length - 16);
const key = Buffer.from(serverSecret, "utf8");
if (key.length !== 32) {
throw new Error("Server Secret must be 32 bytes");
}
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString("utf8");
}
Go 解密示例
package callback
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"errors"
)
func DecryptReservePhone(encryptedPhone, serverSecret string) (string, error) {
data, err := base64.RawURLEncoding.DecodeString(encryptedPhone)
if err != nil {
return "", err
}
if len(data) <= 12+16 {
return "", errors.New("invalid encrypted_phone")
}
nonce := data[:12]
ciphertext := data[12 : len(data)-16]
tag := data[len(data)-16:]
key := []byte(serverSecret)
if len(key) != 32 {
return "", errors.New("Server Secret must be 32 bytes")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCMWithNonceSize(block, 12)
if err != nil {
return "", err
}
ciphertextAndTag := make([]byte, 0, len(ciphertext)+len(tag))
ciphertextAndTag = append(ciphertextAndTag, ciphertext...)
ciphertextAndTag = append(ciphertextAndTag, tag...)
plaintext, err := gcm.Open(nil, nonce, ciphertextAndTag, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}