事件訂閱
事件訂閱用於接收平台推送的業務事件:平台向應用設定的 HTTPS 位址發送回呼,應用側負責驗簽、解密、冪等和快速回應。目前已開放通訊錄成員、部門和成員部門關係變化事件,後續可擴充其他業務事件。
事件訂閱是出站能力:TWT Link 是發送方,應用的服務端是接收方。應用不需要輪詢, 只需提供一個可被企業 TWT Link 私有化實例存取的 HTTP 位址。
當前事件範圍
目前支援使用者、部門、成員與部門關係的新增、變更與刪除事件通知。
推送方式固定為 HTTP 推送:應用在管理後台設定一個請求網址,TWT Link 在事件發生時 以加密 HTTP POST 將事件投遞過去。
投遞的是事件本身,不是差量快照:同一次業務操作可能產生多條事件(例如新增成員會同時
產生 contact.user.created 與 contact.membership.changed),應用需要按事件逐條處理。
前置條件
- 應用為內部應用,且已建立。
- 在管理後台「內部應用 → 事件訂閱 → 訂閱管理」中完成設定:
加密
aes_key、簽名token、請求網址,三者必須同時填寫。 - 開啟需要接收的事件開關,開關預設全部關閉。
- 發布應用版本。
- 應用處於啟用狀態。
推送只使用已發布版本上的設定(請求網址、
aes_key、token、各事件開關)。 儲存設定或調整開關後,必須發布新版本才會生效;輪換憑證同樣需要重新發布。 未發布、已停用或已刪除的應用不會收到任何推送,設定儲存成功也不代表已經開始推送。
訂閱的事件
事件碼(EventType) | 事件名稱 | 實體類型 | 觸發時機 |
|---|---|---|---|
contact.user.created | 使用者新增 | user | 管理員建立成員,或註冊申請審核通過後建立成員 |
contact.user.updated | 使用者資訊變更 | user | 成員資料(姓名、職位、工號、所屬部門等)變更;成員自行修改頭像 |
contact.user.status.changed | 使用者狀態變更 | user | 成員被啟用或禁用;成員被刪除 |
contact.department.created | 部門新增 | department | 建立部門,含企業根部門首次建立 |
contact.department.updated | 部門資訊變更 | department | 部門名稱、父部門、排序或可見性變更 |
contact.department.deleted | 部門刪除 | department | 刪除部門,含批量刪除 |
contact.membership.changed | 成員部門關係變更 | user | 成員加入或移出部門、批量導入成員 |
兩條需要特別注意的語義:
- 沒有獨立的使用者刪除事件。 成員被刪除時下發
contact.user.status.changed, 其Data.deleted為true。 - 部門刪除同樣通過
Data.deleted標記,事件碼仍是contact.department.deleted。
設定項
| 設定項 | 說明 |
|---|---|
| 推送方式 | 固定為 HTTP 推送,不可選擇 |
加密 aes_key | 43 位大小寫字母或數字,即 32 位元組 AES 密鑰的 base64 編碼(去掉 = 填充,解碼時補回)。管理後台可點擊重置按鈕產生 |
簽名 token | 3–32 位大小寫字母或數字 |
| 請求網址 | 接收事件的 HTTP/HTTPS 位址,規則見 4.1 |
aes_key 與 token 由應用自行決定,平台只校驗格式。頁面上的「重置」只產生新的隨機值,
不會自動儲存,需點擊儲存並發布版本後才生效。憑證在頁面上以脫敏形式展示。
請求網址規則
- 必須是
http://或https://開頭的絕對位址,且帶主機名;其他協定一律拒絕。 - 不接受帶使用者資訊的位址(如
http://user:pass@host/path)。 - 私有化部署下,TWT Link 實例需要能夠存取該位址;同一企業網絡內可存取的內網位址同樣可用, 不要求公網域名。
- 儲存時只做位址語法校驗,不會主動探測該位址。連通性以第一次真實事件投遞為準, 請自行確認位址可達、憑證有效(若為 HTTPS)。
- 請求網址不要自帶
signature、timestamp、nonce查詢參數(含兼容拼寫msg_signature、timeStamp):投遞時會按本次投遞重新填充,自帶的值會被覆蓋。
推送請求
事件發生時,TWT Link 向應用設定的請求網址發起一次 HTTP POST:
POST /your/callback?signature=...×tamp=...&nonce=...
Content-Type: application/json
Accept: application/json
{"encrypt":"..."}
| 項目 | 值 |
|---|---|
| 方法 | POST |
| 協議欄位位置 | 查詢串 signature、timestamp、nonce |
| 請求體 | 僅一個 encrypt 欄位的 JSON 物件 |
| 逾時 | 2500ms,逾時即視為投遞失敗 |
| 跳轉 | 不跟隨 3xx 跳轉,3xx 直接視為失敗 |
| 協定欄位取值 | timestamp 為毫秒級 Unix 時間戳字串;nonce 為 16 位隨機字母數字串 |
請求體中的明文經過加密後不會直接出現,簽名和明文分離:簽名在 URL 上,密文在請求體中。
加解密與簽名
事件推送採用釘釘相容的加密回調協定,演算法固定,接收端需要自行實作解密與驗簽。
密鑰與憑據
| 憑據 | 用途 |
|---|---|
aes_key | 43 位文字。解碼方式:先補一個 = 變成長度 44 的字串,再做標準 base64 解碼,得到 32 位元組 AES 密鑰 |
token | 參與簽名計算 |
| 應用 AppId | 即加密訊框中的 owner_key,用於確認事件確實是發給本應用的 |
解密步驟
-
取 URL 上的
signature、timestamp、nonce與請求體中的encrypt,按 6.3 校驗簽名。 -
對
encrypt做 base64 解碼,得到密文。 -
用 32 位元組 AES 密鑰做 AES-256-CBC 解密,IV 取密鑰的前 16 位元組。
-
去除 PKCS#7 填充。注意填充區塊大小是 32 位元組,不是 AES 的 16 位元組, 這是該協定特有的約定,按 16 位元組去填充會失敗。
-
按下面的訊框結構切分明文:
┌────────────────┬──────────────────┬───────────────┬───────────────┐│ 16 位元組隨機前綴 │ 4 位元組明文長度 │ 事件明文 │ owner_key ││ │ (大端無符號整數)│(JSON,UTF-8)│(應用 AppId)│└────────────────┴──────────────────┴───────────────┴───────────────┘ -
用第 2 段宣告的長度截取事件明文,剩餘位元組即
owner_key; 若它與本應用 AppId 不一致,應丟棄該訊息。
簽名演演算法
簽名與 token、timestamp、nonce、encrypt 四個值相關,演算法如下:
1. 將 token、timestamp、nonce、encrypt 四個字串按字典序(字串比較)排序
2. 排序後按順序直接拼接(無分隔符)
3. 對拼接結果做 SHA-1 摘要
4. 取小寫十六進位字串作為簽名
將計算結果與 URL 上的 signature 比較,不一致則拒絕該請求。
encrypt 參與簽名的是 base64 文字本身,不是解碼後的位元組。
參考實作(解密與驗簽)
以下為 Golang 參考實作,可直接對照編寫其他語言的版本。
package callback
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"sort"
)
// decodeAESKey 將 43 位 aes_key 解碼為 32 位元組密鑰。
func decodeAESKey(aesKey string) ([]byte, error) {
return base64.StdEncoding.DecodeString(aesKey + "=")
}
// sign 計算協定簽名:四個值按字典序排序後拼接,取 SHA-1 小寫十六進位。
func sign(token, timestamp, nonce, encrypted string) string {
values := []string{token, timestamp, nonce, encrypted}
sort.Strings(values)
sum := sha1.Sum([]byte(values[0] + values[1] + values[2] + values[3]))
return hex.EncodeToString(sum[:])
}
// Decrypt 校驗簽名並解出事件明文,返回明文與訊框內攜帶的 owner_key。
func Decrypt(token, aesKey, signature, timestamp, nonce, encrypted string) ([]byte, string, error) {
if sign(token, timestamp, nonce, encrypted) != signature {
return nil, "", errors.New("signature mismatch")
}
key, err := decodeAESKey(aesKey)
if err != nil {
return nil, "", err
}
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil || len(ciphertext)%aes.BlockSize != 0 {
return nil, "", errors.New("invalid ciphertext")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, "", err
}
plain := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, key[:aes.BlockSize]).CryptBlocks(plain, ciphertext)
plain, err = unpad32(plain)
if err != nil {
return nil, "", err
}
if len(plain) < 20 {
return nil, "", errors.New("frame too short")
}
length := binary.BigEndian.Uint32(plain[16:20])
start := 20
end := start + int(length)
if end > len(plain) {
return nil, "", errors.New("payload length exceeds frame")
}
return plain[start:end], string(plain[end:]), nil
}
// unpad32 按 32 位元組區塊做 PKCS#7 去填充。
func unpad32(data []byte) ([]byte, error) {
const blockSize = 32
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errors.New("invalid padded length")
}
padding := int(data[len(data)-1])
if padding < 1 || padding > blockSize || padding > len(data) {
return nil, errors.New("invalid padding")
}
if !bytes.Equal(data[len(data)-padding:], bytes.Repeat([]byte{byte(padding)}, padding)) {
return nil, errors.New("invalid padding")
}
return data[:len(data)-padding], nil
}
事件明文結構
解密後得到 UTF-8 編碼的 JSON 文字,結構如下:
{
"EventType": "contact.user.created",
"EventTime": 1757900000000,
"eventId": "6f1c0f4e-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
"BizId": "6f1c0f4e-2a3b-4c5d-8e9f-0a1b2c3d4e5f",
"TimeStamp": 1757900000000,
"entityType": "user",
"entityId": "10001",
"Data": {
"userId": 10001,
"name": "張三",
"position": "產品經理",
"employeeNo": "E1001",
"status": 0,
"departmentIds": [2, 7],
"action": "create"
}
}
| 欄位 | 類型 | 說明 |
|---|---|---|
EventType | string | 事件碼,取值見第 3 章 |
EventTime | int64 | 事件發生時間,毫秒級 Unix 時間戳 |
eventId | string | 事件唯一 ID,冪等依據,見第 9 章 |
BizId | string | 與 eventId 相同,兼容協議欄位 |
TimeStamp | int64 | 與 EventTime 相同,兼容協議欄位 |
entityType | string | 實體類型,user 或 department |
entityId | string | 實體 ID,字串形式的使用者 ID 或部門 ID |
Data | object | 事件業務資料,結構隨 EventType 變化,見 7.1–7.3 |
Data 的欄位多數是可選的:值為空字串、false 或空陣列時不會出現在 JSON 中,
接收端需要按預設值處理,不要依賴欄位一定存在。
兩個例外需要注意:
userId、status、departmentIds三個鍵一定存在,未賦值時取值為0。departmentIds在所屬部門為空時可能是null,解析時請把null與預設值都按空陣列處理。
使用者事件 Data
EventType 為 contact.user.created、contact.user.updated、contact.user.status.changed 時,
Data 的欄位全集如下,並不是所有欄位都會出現:
| 欄位 | 類型 | 說明 |
|---|---|---|
userId | uint64 | 使用者 ID,與 entityId 一致,始終下發 |
name | string | 姓名 |
avatar | string | 頭像位址 |
position | string | 職位 |
employeeNo | string | 工號 |
status | uint8 | 账號狀態:0 正常,1 禁用 |
departmentIds | uint64[] | 目前所屬部門 ID 列表,已排序 |
previousDepartmentIds | uint64[] | 變更前的部門 ID 列表,僅關係變更類事件下發 |
previousStatus | uint8 | 變更前的帳號狀態,僅狀態變更類事件下發 |
action | string | 業務動作,見下表 |
deleted | bool | 為 true 表示該使用者已被刪除 |
因此接收端必須按「欄位可能缺失」來解析:例如 contact.user.updated 既可能是完整資料
變更,也可能只是頭像變更,後者不含 name、position、employeeNo。需要完整資料時
請呼叫取得成員詳情介面補齊,不要用事件欄位覆蓋本地完整資料。
action 取值:
action | 出現的事件 | 意義 |
|---|---|---|
create | contact.user.created | 單個建立成員,或註冊審核通過後建立 |
import | contact.user.created | 批量匯入建立 |
update | contact.user.updated、contact.user.status.changed、contact.membership.changed | 常規更新 |
status | contact.user.status.changed | 通過啟用/禁用操作變更狀態 |
delete | contact.user.status.changed、contact.membership.changed | 刪除成員 |
add | contact.membership.changed | 成員加入部門 |
注意:僅狀態變更的事件(action=status)departmentIds 為空陣列,
不要把它理解為「該使用者已不屬於任何部門」,需要部門資訊時請以 contact.user.updated
或 contact.membership.changed 為準。
部門事件 Data
EventType 以 contact.department. 開頭時:
| 欄位 | 類型 | 說明 |
|---|---|---|
departmentId | uint64 | 部門 ID,與 entityId 一致 |
name | string | 部門名稱 |
parentId | uint64 | 父部門 ID,根部門的父部門 ID 為 0 |
sort | uint32 | 排序值 |
contactVisible | uint8 | 可見性:0 全員可見,1 僅本部門及下級部門可見 |
previousName | string | 變更前的部門名稱,僅更新事件下發 |
previousParentId | uint64 | 變更前的父部門 ID,僅更新事件下發 |
previousSort | uint32 | 變更前的排序值,僅更新事件下發 |
previousContactVisible | uint8 | 變更前的可見性,僅更新事件下發 |
deleted | bool | 為 true 表示該部門已被刪除 |
成員部門關係事件 Data
EventType 為 contact.membership.changed 時,entityType 為 user,entityId 為使用者 ID:
| 欄位 | 類型 | 說明 |
|---|---|---|
userId | uint64 | 使用者 ID |
departmentIds | uint64[] | 變更後的所屬部門 ID 列表 |
previousDepartmentIds | uint64[] | 變更前的所屬部門 ID 列表 |
status | uint8 | 账號狀態 |
action | string | add(加入)、update(調整)、delete(隨成員刪除)、import(批量匯入) |
請以本章列出的欄位為準,不要依賴未列出的鍵。
接收端回應
接收端必須在 2500ms 內返回 HTTP 2xx,且響應體是加密信封:
{
"msg_signature": "6f1c0f4e...",
"encrypt": "....",
"timeStamp": "1757900000123",
"nonce": "Ab3dE9fGh1JkLm2n"
}
| 欄位 | 說明 |
|---|---|
encrypt | 將明文 success(7 個 ASCII 字元)用同一套協議加密後的 base64 文字 |
msg_signature | 對上一步的 encrypt 與本次響應的 timeStamp、nonce、token 計算出的簽名,演算法同 6.3 |
timeStamp | 本次響應自行產生的毫秒時間戳,欄位名首字母大寫 S;也接受 timestamp |
nonce | 本次響應自行產生的隨機串 |
響應中的 timeStamp 與 nonce 由接收端自行產生,不需要回傳請求裡的值;
簽名用接收端產生的值重新計算即可。
滿足以下全部條件才算投遞成功:
- HTTP 狀態碼為 2xx;
- 響應體是合法 JSON 且包含
msg_signature、encrypt、timeStamp、nonce四個欄位; - 簽名校驗通過;
- 解密後的明文恰好是
success。
響應體上限 64 KiB,超出即判為失敗。返回明文 success(未加密)不算成功。
重試與冪等
事件與業務寫入在同一個事務中提交,隨後由 TWT Link 的投遞任務展開並回調。
投遞失敗(逾時、非 2xx、響應體不符合第 8 章要求、網絡錯誤)時,TWT Link 會自動重試, 退避從 2 秒開始逐次翻倍、上限 1 分鐘,最多嘗試 12 次;達到上限後事件落為失敗終態, 不再投遞,TWT Link 不保證事件最終一定送達。重要業務請以應用側主動拉取通訊錄介面 (見取得部門列表)作為備援。
在重試與並發下,接收端仍可能收到重複事件,必須按 eventId 冪等:
- 以
eventId作為唯一鍵寫入資料庫或快取,重複事件直接返回成功。 - 冪等窗口建議不短於 24 小時。
- TWT Link 側的去重記錄保留 7 天,失敗記錄保留 30 天,之後不再具備平台側去重依據, 請勿依賴平台側記錄做長期去重。
- 同一次業務操作會產生多條不同
eventId的事件(例如建立成員同時產生contact.user.created與contact.membership.changed),它們是獨立事件,不能互相去重。
關於順序:投遞鏈路不保證事件到達順序,同一實體的多次變更也可能亂序或交疊到達,
重試還會讓較早的事件晚於較新的事件送達。接收端應結合 EventTime 與 eventId 做狀態合併,
不要按到達順序直接覆蓋本地數據。
排障
| 現象 | 排查方向 |
|---|---|
| 設定儲存成功但收不到事件 | 是否已發布版本;應用是否已啟用;對應事件開關是否打開;觸發的事件是否在一期七項範圍內 |
| 完全收不到請求 | TWT Link 實例能否存取該位址(內網位址需同網可達);位址協定與主機名是否合法;端點是否被防火牆或鑑權攔截 |
| 簽名校驗不通過 | token 是否為目前已發布版本上的值;encrypt 參與簽名的是 base64 文字;排序是否為字典序 |
| 解密失敗或填充錯誤 | aes_key 是否為目前已發布版本上的值;解碼前是否補了 =;去填充區塊大小是否為 32 |
| 解密成功但 JSON 解析失敗 | 明文是 UTF-8 JSON,需按 Data 結構解析,注意欄位可能缺失 |
報 owner_key 不相符 | 訊框尾端的 AppId 是否與本應用一致,確認事件不是發給其他應用的 |
| 一直被重試 | 響應是否在 2500ms 內返回;是否返回 2xx;響應體是否為加密的 success;響應體是否超過 64 KiB |
關於
check_url:目前版本不會主動發起 URL 校驗事件。設定儲存時不會探測請求網址, 接收到EventType為check_url的請求即可忽略。
相關檔案
事件回呼不是開放 API 的統一回應格式;完整資料變更仍應透過取得部門列表查詢。