跳至主要内容

事件訂閱

事件訂閱用於接收平台推送的業務事件:平台向應用設定的 HTTPS 位址發送回呼,應用側負責驗簽、解密、冪等和快速回應。目前已開放通訊錄成員、部門和成員部門關係變化事件,後續可擴充其他業務事件。

事件訂閱是出站能力:TWT Link 是發送方,應用的服務端是接收方。應用不需要輪詢, 只需提供一個可被企業 TWT Link 私有化實例存取的 HTTP 位址。

當前事件範圍​

目前支援使用者、部門、成員與部門關係的新增、變更與刪除事件通知。

推送方式固定為 HTTP 推送:應用在管理後台設定一個請求網址,TWT Link 在事件發生時 以加密 HTTP POST 將事件投遞過去。

投遞的是事件本身,不是差量快照:同一次業務操作可能產生多條事件(例如新增成員會同時 產生 contact.user.created 與 contact.membership.changed),應用需要按事件逐條處理。

前置條件​

  1. 應用為內部應用,且已建立。
  2. 在管理後台「內部應用 → 事件訂閱 → 訂閱管理」中完成設定: 加密 aes_key、簽名 token、請求網址,三者必須同時填寫。
  3. 開啟需要接收的事件開關,開關預設全部關閉。
  4. 發布應用版本。
  5. 應用處於啟用狀態。

推送只使用已發布版本上的設定(請求網址、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_key43 位大小寫字母或數字,即 32 位元組 AES 密鑰的 base64 編碼(去掉 = 填充,解碼時補回)。管理後台可點擊重置按鈕產生
簽名 token3–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=...&timestamp=...&nonce=...
Content-Type: application/json
Accept: application/json

{"encrypt":"..."}
項目值
方法POST
協議欄位位置查詢串 signature、timestamp、nonce
請求體僅一個 encrypt 欄位的 JSON 物件
逾時2500ms,逾時即視為投遞失敗
跳轉不跟隨 3xx 跳轉,3xx 直接視為失敗
協定欄位取值timestamp 為毫秒級 Unix 時間戳字串;nonce 為 16 位隨機字母數字串

請求體中的明文經過加密後不會直接出現,簽名和明文分離:簽名在 URL 上,密文在請求體中。

加解密與簽名​

事件推送採用釘釘相容的加密回調協定,演算法固定,接收端需要自行實作解密與驗簽。

密鑰與憑據​

憑據用途
aes_key43 位文字。解碼方式:先補一個 = 變成長度 44 的字串,再做標準 base64 解碼,得到 32 位元組 AES 密鑰
token參與簽名計算
應用 AppId即加密訊框中的 owner_key,用於確認事件確實是發給本應用的

解密步驟​

  1. 取 URL 上的 signature、timestamp、nonce 與請求體中的 encrypt,按 6.3 校驗簽名。

  2. 對 encrypt 做 base64 解碼,得到密文。

  3. 用 32 位元組 AES 密鑰做 AES-256-CBC 解密,IV 取密鑰的前 16 位元組。

  4. 去除 PKCS#7 填充。注意填充區塊大小是 32 位元組,不是 AES 的 16 位元組, 這是該協定特有的約定,按 16 位元組去填充會失敗。

  5. 按下面的訊框結構切分明文:

    ┌────────────────┬──────────────────┬───────────────┬───────────────┐
    │ 16 位元組隨機前綴 │ 4 位元組明文長度 │ 事件明文 │ owner_key │
    │ │ (大端無符號整數)│(JSON,UTF-8)│(應用 AppId)│
    └────────────────┴──────────────────┴───────────────┴───────────────┘
  6. 用第 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"
}
}
欄位類型說明
EventTypestring事件碼,取值見第 3 章
EventTimeint64事件發生時間,毫秒級 Unix 時間戳
eventIdstring事件唯一 ID,冪等依據,見第 9 章
BizIdstring與 eventId 相同,兼容協議欄位
TimeStampint64與 EventTime 相同,兼容協議欄位
entityTypestring實體類型,user 或 department
entityIdstring實體 ID,字串形式的使用者 ID 或部門 ID
Dataobject事件業務資料,結構隨 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 的欄位全集如下,並不是所有欄位都會出現:

欄位類型說明
userIduint64使用者 ID,與 entityId 一致,始終下發
namestring姓名
avatarstring頭像位址
positionstring職位
employeeNostring工號
statusuint8账號狀態:0 正常,1 禁用
departmentIdsuint64[]目前所屬部門 ID 列表,已排序
previousDepartmentIdsuint64[]變更前的部門 ID 列表,僅關係變更類事件下發
previousStatusuint8變更前的帳號狀態,僅狀態變更類事件下發
actionstring業務動作,見下表
deletedbool為 true 表示該使用者已被刪除

因此接收端必須按「欄位可能缺失」來解析:例如 contact.user.updated 既可能是完整資料 變更,也可能只是頭像變更,後者不含 name、position、employeeNo。需要完整資料時 請呼叫取得成員詳情介面補齊,不要用事件欄位覆蓋本地完整資料。

action 取值:

action出現的事件意義
createcontact.user.created單個建立成員,或註冊審核通過後建立
importcontact.user.created批量匯入建立
updatecontact.user.updated、contact.user.status.changed、contact.membership.changed常規更新
statuscontact.user.status.changed通過啟用/禁用操作變更狀態
deletecontact.user.status.changed、contact.membership.changed刪除成員
addcontact.membership.changed成員加入部門

注意:僅狀態變更的事件(action=status)departmentIds 為空陣列, 不要把它理解為「該使用者已不屬於任何部門」,需要部門資訊時請以 contact.user.updated 或 contact.membership.changed 為準。

部門事件 Data​

EventType 以 contact.department. 開頭時:

欄位類型說明
departmentIduint64部門 ID,與 entityId 一致
namestring部門名稱
parentIduint64父部門 ID,根部門的父部門 ID 為 0
sortuint32排序值
contactVisibleuint8可見性:0 全員可見,1 僅本部門及下級部門可見
previousNamestring變更前的部門名稱,僅更新事件下發
previousParentIduint64變更前的父部門 ID,僅更新事件下發
previousSortuint32變更前的排序值,僅更新事件下發
previousContactVisibleuint8變更前的可見性,僅更新事件下發
deletedbool為 true 表示該部門已被刪除

成員部門關係事件 Data​

EventType 為 contact.membership.changed 時,entityType 為 user,entityId 為使用者 ID:

欄位類型說明
userIduint64使用者 ID
departmentIdsuint64[]變更後的所屬部門 ID 列表
previousDepartmentIdsuint64[]變更前的所屬部門 ID 列表
statusuint8账號狀態
actionstringadd(加入)、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 由接收端自行產生,不需要回傳請求裡的值; 簽名用接收端產生的值重新計算即可。

滿足以下全部條件才算投遞成功:

  1. HTTP 狀態碼為 2xx;
  2. 響應體是合法 JSON 且包含 msg_signature、encrypt、timeStamp、nonce 四個欄位;
  3. 簽名校驗通過;
  4. 解密後的明文恰好是 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 的統一回應格式;完整資料變更仍應透過取得部門列表查詢。