我讓工作流在 Box 檔案上留言 @提及上傳者,並回報處理進度 — 開始、完成與失敗
我讓工作流監看 Box 資料夾。每個檔案上傳後,工作流會在處理開始、完成和失敗時於該檔案上留言,並依 modified_by 欄位提及上傳者。

本頁目錄
引言
我做了一個工作流,每當有人把檔案上傳到 Box 資料夾時就會被啟動並處理該檔案。但每次我把檔案放進去時,都看不出它有沒有在處理。無論成功或失敗,上傳者都收不到通知,只能過一段時間再回來確認檔案是否處理好了。
我希望工作流能夠即時回饋處理狀況,讓上傳者了解自己的檔案處理到哪裡,但我不想另外建立 UI,也不想在 Slack 上通知,因為使用者一多,通知的數量也會跟著膨脹。
我的解決方法是在上傳的檔案上留言,並 @提及上傳者。留言的時機是開始處理時、處理完成並附上結果連結時,以及最終確定失敗時。Box 會用電子郵件和收件匣通知被提及的人,所以等待中的上傳者不必打開 Box,也能收到進度。
Box 有哪些方式能讓上傳者看到進度?
我列出了幾個不必另外建立 UI,就能讓上傳檔案的人看到處理進度的方式:
| 方式 | 上傳者如何得知 | 副作用 |
|---|---|---|
| 鎖定檔案 | 檔案名旁邊會出現鎖頭圖示,不會通知 | 看起來像「你不能編輯」,而且工作中途當掉時鎖會留下來 |
| 寫入描述欄 | 顯示在詳細資料面板,不會通知 | 會覆寫掉使用者自己填寫的內容 |
改名為 [processing] … | 檔案清單中的名稱改變,不會通知 | 會觸發 FILE.RENAMED,讓啟動工作流的 Box Webhook 再執行一次 |
| Box Skills 狀態卡片 | 顯示在預覽的側邊欄,不會通知 | 只有打開檔案預覽時才看得到 |
| 附 @提及的留言 | 顯示在留言區,並以電子郵件和 Box 收件匣通知 | 開啟留言電子郵件通知的協作者也會收到郵件通知 |
在檔案上留言,並提及上傳者
這個工作流由兩個元件組成:
- Box Webhook:每次有檔案上傳,就把一則訊息放進佇列。
- worker:從佇列取出訊息、處理檔案,並把結果檔案寫回 Box。
處理進度則由 worker 在檔案上發出的三則留言回報。worker 透過 Box 的 POST /comments 端點留言。@提及要放在 tagged_message 裡。Box 會在這個欄位中尋找 @[user_id:name],並通知對應的使用者。
- 開始:worker 先取得檔案的鎖,再確認是否支援該檔案的格式,並確認該檔案的當前版本尚未處理完成後,留言開始處理。
- 完成:處理完成後,worker 會在原本的檔案上留言,並附上結果檔案的連結。
- 失敗:worker 只在最後一次傳遞也失敗時才會留言。佇列最多會傳遞同一則訊息三次,較早的失敗仍可能在下一次傳遞時成功,所以只有最後一次傳遞也失敗時,worker 才會在該檔案上留言告知處理失敗。

負責發出留言的是包裝 Box API 的 connector 裡的 post_comment(),程式碼只有幾行。mention 是必填參數,因為沒有通知對象的留言就不算是進度回報:
_MENTION_NAME_DELIMITERS = str.maketrans("", "", "[]:")
def post_comment(self, file_id: str, text: str, *, mention: Person) -> None: item = CreateCommentItem(id=file_id, type=CreateCommentItemTypeField.FILE) name = mention.name.translate(_MENTION_NAME_DELIMITERS).strip() or mention.id tagged = f"@[{mention.id}:{name}] {text}" self._call(lambda: self._client.comments.create_comment(None, item, tagged_message=tagged))顯示名稱會去掉 [、] 和 :,因為這三個字元就是提及語法本身的分隔符號,而 Box 沒有公開跳脫它們的方式。以顯示名稱 Sato [QA]: Tokyo 為例:
@[12345:Sato [QA]: Tokyo]@[12345:Sato QA Tokyo]去掉前的字串在語法上會在 QA 後面的 ] 就結束提及。名稱只是提及的標籤。Box 是依 user_id 決定通知要送給誰,所以去掉這些字元也不會改變收件人。最後一行為什麼要傳 None,會在後面的陷阱 1 說明。
留言發送失敗時,不會讓工作跟著失敗。如果讓錯誤往外拋,工作就會回到佇列。這樣一來,如果完成留言發送失敗,已經上傳過結果的檔案就會被重新處理一次。所以失敗時只記錄一則警告。警告裡只有例外的型別,不會有上傳者的名稱或留言內容。
要 @提及誰:用 modified_by,而不是 created_by
等待處理結果的是檔案的 modified_by,也就是上傳目前這個版本的使用者。worker 本來就會呼叫 GET /files/{id} 取得檔名和上層資料夾,所以要取得上傳者,只需在 fields 裡多加一項,不必多送一次請求:
file = self._call(lambda: self._client.files.get_file_by_id( file_id, fields=["name", "parent", "size", "version_number", "modified_by"]))為了確認哪個欄位會跟著上傳者變動,我先透過自己的帳戶上傳了第一個版本。第一個版本的 modified_by 和 created_by 都指向了我。透過應用程式的服務帳戶上傳第二個版本之後,modified_by 變成了服務帳戶,created_by 仍然是我。
我再用自己的帳戶上傳第三個版本後,modified_by 又變回了我。由此可知,created_by 記錄的是最早上傳這個檔案的人。如果用 created_by 當作通知對象,一旦最早上傳的人和更新版本的人不同,就會提及錯誤的人。
由應用程式自己的帳戶上傳的版本不需要留言。因此當 modified_by 與 GET /users/me 回傳的帳戶(也就是應用程式自己的帳戶)相同時,worker 會略過留言。
即使關閉留言電子郵件通知,被 @提及時仍然收到郵件通知
上傳者即使關掉了資料夾的留言電子郵件通知,仍然會收到提及的通知。我在資料夾的通知設定中取消勾選「Adds a comment」,關閉留言時的寄信通知。之後應用程式發出一則提及時,電子郵件和 Box 的收件匣兩邊的通知都還是送達了。


因此,這些留言不會讓共用資料夾的成員被信件淹沒。不想收到別人檔案進度的協作者,只要關掉那個資料夾的留言電子郵件通知即可。對於自己上傳的檔案,仍然會透過 @提及收到通知。
陷阱 1:同時送出 message 和 tagged_message 時,Box 會回傳 400
Box Python SDK(box_sdk_gen)的 create_comment() 會同時送出 message 和 tagged_message,而 Box 會拒絕這樣的組合。這個 SDK 把 message 設成必填的位置參數,並用這兩個鍵組出請求主體:
request_body: Dict = { 'message': message, 'tagged_message': tagged_message, 'item': item,}把留言內容傳給 message、把加上提及的同一段文字傳給 tagged_message,看起來正是函式簽章要求的寫法。但是實際呼叫 Box 時,每一次都回傳了這個錯誤:
Message: 400 Bad Request; Request ID: … Status code: 400 Code: bad_requestBox 自己的指南也寫明,有 @提及時要改用 tagged_message 而非平常使用的 message。但 SDK 規定 message 必須傳入,所以我傳入了 None。SDK 的 serialize() 會丟掉值為 None 的鍵,所以實際送出的請求主體裡就只剩下了 tagged_message:
# SDK 的參數create_comment(text, item, tagged_message=tagged)
# 送出的請求主體{ "message": "Processing has started.", "tagged_message": "@[12345:Sato] Processing has started.", "item": { "id": "…", "type": "file" }}# SDK 的參數create_comment(None, item, tagged_message=tagged)
# 送出的請求主體{ "tagged_message": "@[12345:Sato] Processing has started.", "item": { "id": "…", "type": "file" }}陷阱 2:如果只在第一次工作傳遞時通知處理開始,搶不到鎖時通知會被漏掉
如果只在第一次傳遞時發出開始留言,那麼當那次傳遞拿不到檔案的鎖時,開始留言就會漏掉。worker 在 DynamoDB 的工作紀錄裡為每個檔案保存一把鎖,避免兩個工作同時處理同一個檔案。如果工作發現鎖已經被其他工作取走,就會回到佇列,等待之後的傳遞。
Box 可能在同一時間針對同一個檔案送出 FILE.MOVED 和 FILE.UPLOADED,所以拿不到鎖是很常見的情況。佇列會在 ApproximateReceiveCount 中計算傳遞次數。如果只在次數為 1 時才通知,遇到這種情況通知就會被跳過。
第 1 次傳遞拿不到鎖,什麼都沒通知。第 2 次傳遞拿到鎖並處理了檔案,但次數是 2,所以同樣什麼都沒通知。最後上傳者只收到了完成留言,前面的開始留言卻被跳過了。
重新執行也會讓同樣的判定失效。worker 忙碌時如果有新版本進來,worker 會在同一次傳遞裡再處理一次。這時次數如果已經是 2 以上,這個新版本的開始就永遠不會被通知。
解決方法是把已經通知過開始的檔案版本,記在 DynamoDB 裡那筆保存鎖的紀錄上,欄位名稱是 start_notified。worker 用條件式更新寫入這個欄位:只有在 start_notified 還不是目前這個版本時,寫入才會成功。所以同一個版本只會有一次寫入成功,開始留言也只會發一次:
def claim_start_notice(self, key: str, *, source_version: str | None) -> bool: try: self._table.update_item( Key={"pk": key}, UpdateExpression="SET start_notified = :ver", ConditionExpression=( "attribute_not_exists(start_notified) OR start_notified <> :ver" ), ExpressionAttributeValues={":ver": source_version or ""}, ) except ClientError as exc: if exc.response["Error"]["Code"] != "ConditionalCheckFailedException": raise return False return True工作完成時會移除 start_notified,失敗時則會保留,因此:
- 第 1 次傳遞拿不到鎖:
start_notified還沒有寫入,所以第 2 次傳遞會通知開始。 - 失敗後重新傳遞:
start_notified已經是這個版本,所以重新傳遞時不會通知。 - 為了新版本重新執行:完成時已經清除
start_notified,所以重新執行時會通知。 - 最終失敗後才進來的新版本:
start_notified還是舊版本,所以會通知新版本的開始。
總結
現在 Box 會告訴上傳者:檔案開始處理了、處理完成並附上結果連結,或是告知處理失敗。檔案的留言區也會留下這些紀錄。我完全是利用了 Box 本身的機制,現在 worker 會針對處理狀態留下三種留言,並在每一則裡 @提及上傳者。
這次做下來,我學到的大多是關於 Box 的事。提及的通知和留言的電子郵件通知是分開的,所以協作者即使關掉某個資料夾的留言郵件通知,也不會漏掉自己檔案的進度。modified_by 指的是目前版本的上傳者,而 created_by 則是指向第一個上傳的人。
SDK 的 create_comment() 要把 message 傳成 None,請求才只會帶 tagged_message,Box 也才會接受。開始通知則依檔案版本記在 start_notified,這樣不論哪一次傳遞拿到鎖,每個版本都只會通知一次開始。
