半夜問兩碗爌肉飯,系統留下詢問,另一端卻可能沒人知道。今天把建單、通知、認領與結案拆成可核對的狀態,先用 SQLite 重現收件匣與舊按鈕競爭,再界定真人驗收需要的證據。讀者帶走的是一條能接回既有服務單、失敗時也知道下一步的交接路徑。
A saved request is not a delivered notification, and a delivered notification is not a human commitment. This chapter separates those responsibilities through a small volunteer inbox. A SQLite example exercises conditional claims, durable notification intent, and recovery after an uncertain sender response. External delivery and human acknowledgement remain separate acceptance steps. The result is a runnable local contract and a concrete checklist for connecting the existing LINE service to an accountable recipient.
現場一句話:媽媽留下詢問後,畫面說等待人工,另一端真的知道嗎?
只准後端決定的規則:同一單的通知與認領各有紀錄,誰能接手由授權與條件更新決定。
Google AI 用到/刻意不用:沿用 Gemini 與 ADK 的詢問入口;認領、通知狀態與結案由後端管理。
五分鐘入口:python3 -m examples.day19.inbox_contract --out out/day19/first-run。
這篇不能證明:本機替身接受通知,不證明 LINE 送達或真人認領。
| 元件 | 本篇角色 | 實作範圍 |
|---|---|---|
| Gemini/Google ADK | 承接前篇理解問句與工具分工 | 本次收件匣演練不新增模型呼叫 |
| SQLite | 保存收件匣投影、通知意圖與認領版本 | 本篇新增的隔離範例 |
| Firestore | 既有雲端資料的整合目標 | 交易接線須另驗,不把 SQLite 當雲端部署 |
| Cloud Run/LINE | 既有服務與真人接收通道 | 真實通知、按鈕事件與手機回條另列驗收 |
建立單據,不等於通知真人;通知送達,也不等於真人受理。 本次先把這條分界做成可執行的本機契約,再把真人端點接入。兩者有各自的證據欄位。
《爌肉之城》把彰化的飲食節奏整理成地方故事。白色方塊工作室、旅庫彰化與我們經營的彰化旅行+,關心的不只是哪碗飯好吃,也包括遊客走進地方之後,遇到問題怎麼得到幫忙。
維運彰化蔬食節與地方 LINE 服務,我很在意「接下來有人處理」這句話。鄉親可能只看懂回條上的幾個字,便放心把手機收起來。系統若只是保存資料,卻寫得像窗口已經接聽電話,等待的成本就全丟給了他。
Day 18 的複合問句是:「現在哪裡有開著的爌肉飯?可以幫我預約兩碗帶走嗎?」固定說明卡提供三個入口,其中一個是「留下服務詢問」。那只是入口;使用者仍要完成既有的確認流程,才有服務單可交接。[1]
我把這次的新能力定義成「讓既有單據有接收端」,而非另外生出第二張單。原本的 request_id 要一路帶到收件匣。半夜的詢問可以等待有權限的人查看,至於幾點值班、多久回覆,應由營運安排承諾。程式沒有值班表,就先不替人答應清晨一定回電。
我也把收件匣和預約系統分清楚。遊客留下「想請窗口協助確認」的詢問,是讓人決定能否協助,不是由系統替店家保留兩碗飯。志工的工作項目可以是回覆資訊、提醒限制或另行聯絡;最後怎麼處理,要有接手者自己的紀錄。這個差別會影響回條的一整句話:寫「已建立詢問」比寫「訂餐處理中」更接近目前真正提供的服務。
第一版設計已有四種狀態與固定文案,但資料放在記憶體字典裡,通知也只建立本機回條。因此,以下把第一版設計與新增的 SQLite 補強範例分開說明,實機接線仍列在第七節,沒有把終端機印出的成功當成真人到場。
本篇新增程式位於既有專案的 examples/day19/,使用原來 Python 環境。這個範例只用標準函式庫,與正式資料庫隔離;輸出目錄採新名稱,避免蓋掉前次紀錄。
python3 -m unittest examples.day19.test_inbox_contract -v
python3 -m examples.day19.inbox_contract --out out/day19/first-run
打開 REPORT.json,會看到 SQLITE_OFFLINE_FAKE_SENDER、network_calls: 0、兩次認領結果 [true, false],以及 state: human_claimed。單號由演練產生,前綴標示 synthetic;它是測試資料,不是地方遊客的案件。
演練中,測試接手者的名單由程式預先建立,並非從使用者輸入推論。讀者可以先打開收件匣清單,看每張卡只有 request_id、類別、狀態與認領版本,再查使用者端只看得到哪幾個欄位。把兩種視角分開,不只是把同一段 JSON 的字縮小:管理者需要操作依據,使用者需要知道等待進度,兩者不必收到相同資訊。
同一目錄的 inbox.sqlite 有兩張重要表:inbox 保存交接投影,outbox 保存待發通知。後者像寄件前的待辦清單:先寫下要寄哪一單、寄給誰、內容與重試識別,再交給傳送器。關掉 Python 後檔案仍在,後續可以從原狀態接續。
| 操作 | 應看到的事實 | 不應推導的結論 |
|---|---|---|
| 匯入已確認請求 | 收件匣與通知意圖同時保存 | 又建立一筆正式服務單 |
| 假傳送器接受 | 通知紀錄變成 accepted |
LINE 好友已讀 |
| 第一位測試志工認領 | human_claimed,版本增加 |
線下服務已完成 |
| 第二位使用舊版本認領 | 條件更新零筆,回覆失敗 | 原案件被刪除 |
範例的兩張資料表如下。fingerprint 用來拒絕同號異內容,retry_key 則對應外部通知請求;它們處理不同責任。provider_id 要等通道接受後才填,預先保存的是通知意圖,不是成功證明。
CREATE TABLE IF NOT EXISTS inbox(
request_id TEXT PRIMARY KEY, owner_ref TEXT NOT NULL,
category TEXT NOT NULL, fingerprint TEXT NOT NULL,
state TEXT NOT NULL, claim_version INTEGER NOT NULL,
claimed_by TEXT, updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox(
request_id TEXT PRIMARY KEY REFERENCES inbox(request_id),
recipient_ref TEXT NOT NULL, retry_key TEXT UNIQUE NOT NULL,
payload TEXT NOT NULL, status TEXT NOT NULL,
first_attempt REAL, attempts INTEGER NOT NULL DEFAULT 0,
provider_id TEXT
);
這個最小模型讓每張單只有一份通知意圖。未來要轉交另一個值班群組時,應定義新的通知事件與對帳規則,不能直接沿用原重試鍵卻更換接收者。先把限制寫清楚,比在欄位裡塞進任意收件人更容易維運。
這份投影需要由可信接點匯入。正式入口應先向既有後端核對單據、擁有者與同意狀態,再呼叫 register_confirmed();這個函式名稱本身不是授權證明。使用者查詢則帶入伺服器端辨識的帳號,不能拿別人的單號讀資料。
第一版用 notification_delivered 表示通知階段。新增範例改用 notification_accepted,刻意把名稱縮回傳送通道接受請求這件事;這是本篇的命名調整,不是悄悄改寫前篇的業務狀態。
request_created
→ notification_accepted
→ human_claimed
→ resolved
| 狀態 | 新增範例的判定 | 使用者可理解的意思 |
|---|---|---|
request_created |
已保存確認單的交接投影 | 詢問已保存,等待通知窗口 |
notification_accepted |
傳送器取得通道接受回條 | 通知請求已被接受,等待接手 |
human_claimed |
已授權接手者完成條件更新 | 已有人接手處理 |
resolved |
接手者登錄結案 | 已登錄結案,不據此推論現場服務品質 |
認領用到 比較並交換(CAS):卡片帶著當時的版本,資料庫只有在版本仍相同、尚無接手者時才更新。像志工在共同登記簿上簽名,重點不是大家都先看一眼空格,而是簽下去時仍只有一個人能取得它。
以下節錄新增 inbox_contract.py。BEGIN IMMEDIATE 讓 SQLite 寫入交易先取得需要的寫入權;正式 Firestore 整合應改用它的交易介面,而非照貼 SQLite SQL。[2][3]
@contextmanager
def transaction(self):
with self.connection() as db:
db.execute("BEGIN IMMEDIATE")
try:
yield db
db.commit()
except BaseException:
db.rollback()
raise
def claim(self, request_id: str, volunteer: str,
expected_version: int) -> bool:
if type(expected_version) is not int or expected_version < 1:
raise ValueError("INVALID_VERSION")
with self.transaction() as db:
self.authorize(db, volunteer)
changed = db.execute(
"UPDATE inbox SET state='human_claimed', claimed_by=?, "
"claim_version=claim_version+1, updated_at=? "
"WHERE request_id=? AND claim_version=? AND claimed_by IS NULL "
"AND state IN ('request_created','notification_accepted')",
(volunteer, self.clock(), request_id, expected_version),
).rowcount
return changed == 1
rowcount 為一才表示這次取得認領,零表示條件已變。另一位志工失敗後應重新讀取授權範圍內的狀態,再顯示「這筆已有接手者」;不要把資料庫內的完整人員 ID 拼進使用者訊息。範例另外拒絕布林值當作版本號,因為在 Python 中,True 與整數一的關係可能讓太寬的型別檢查放行錯誤輸入。
這裡的授權檢查也在同一交易內。結案時再查一次接手者權限,避免人員已撤權,舊畫面卻仍能修改狀態。對使用者的查詢只回單號、狀態與更新時間;接手者私人識別留在內部紀錄。

圖 1:服務單建立、隔離寄件匣(outbox)、通道接受回條、真人樂觀鎖認領與結案宣告架構。框選本機演練範圍,標明通道接受不等於真人受理。
第一版設計以記憶體字典保存寄送紀錄,可防同一物件裡連續呼叫兩次,卻沒有解決重啟或跨實例共享。新增 outbox 把 retry_key、接收者與負載先保存,傳送動作放在交易之外。Firestore 交易可能重做,將外部推播放進交易函式會讓副作用難以控制。[3]
同一個 request_id 再次匯入時,我還核對擁有者、類別與接收端形成的指紋。內容相同才查回既有投影;同號卻換成另一位使用者,直接拒絕。去重若只查「這個鍵在不在」,可能會把一個人的操作錯接到另一個人的單。原單與收件匣的資料庫若分離,兩邊更需要可重跑的對帳程序,而不是宣稱跨資料庫已經有一筆共同交易。
最麻煩的不是明確失敗,而是送出後連線斷了:對方可能已接受,我這邊卻沒拿到回條。此時範例標記 unknown,保留原本通知意圖。重試沿用相同內容與識別,已有接受紀錄則直接查回。
LINE 的 X-Line-Retry-Key 支援推播重試去重;已接受的相同鍵再次送出會有對應回應。這個機制有有效期間,也要求內容與接收者相同,不能延伸成任意時間都能保證送達一次。[4]
保存 request_id、recipient_ref、payload、retry_key
→ 交易提交
→ 呼叫傳送器
→ 取得通道接受回條,另寫通知紀錄
送出後逾時
→ 保留 unknown
→ 有效期間內使用原鍵核對/重試
→ 超過期間先人工對帳,不換新鍵盲送
下面節錄通知器離開交易後的處理。sender 回傳的是通道接受識別;在本機示範裡,它來自 FakeSender。正式 adapter 須從實際 API 回應核對,不能只要沒有丟例外,就自行製造成功回條。
try:
provider_id = sender(row)
if not isinstance(provider_id, str) or not provider_id:
raise ValueError("CHANNEL_ACCEPTANCE_REQUIRED")
except Exception:
with self.transaction() as db:
db.execute(
"UPDATE outbox SET status='unknown' WHERE request_id=? "
"AND status!='accepted'", (request_id,)
)
raise
with self.transaction() as db:
db.execute(
"UPDATE outbox SET status='accepted',provider_id=? WHERE request_id=?",
(provider_id, request_id)
)
db.execute(
"UPDATE inbox SET state='notification_accepted',updated_at=? "
"WHERE request_id=? AND state='request_created'",
(self.clock(), request_id)
)
失敗分類也要保守。輸入或權限錯誤適合先修請求,不是一直重送;網路逾時才有回條不確定的問題。正式傳送器應回報錯誤種類,搭配值班者能看懂的狀態。這份小範例把例外先保留為未知,不提供背景無限重試,也沒有以重試次數換取更漂亮的成功率。
模型不需要參與這個判定。未來若用 Gemini 摘要長句,摘要是待驗證的輔助內容,原確認單才是事實來源。本次沒有這段模型接線,所以收件匣先只顯示類別與單號;查詢詳細內容交給已授權窗口,降低群組通知外洩的風險。
第一版設計的電話遮蔽也只是局部規則,並未處理姓名、地址或各種書寫方式。把部分號碼換成星號,並不等於完成匿名化。我寧可讓第一則通知少一點內容,再讓有需要的接手者進入受權限保護的畫面。
第一版設計最有教學價值的地方,是它讓我追到一個很具體的矛盾:產生通知卡時,按鈕帶的是版本一;接著記錄通知已送達時,又把案件版本加成二。照卡片真的帶來的版本去認領,會得到:
CONCURRENCY_CONFLICT_EXPECTED_V1_GOT_V2
第一版設計的示範卻手動傳入二,所以原本的測試仍然通過。這不是少打一個數字;它提醒我,應測使用者實際按到的資料,而非在測試裡替使用者猜新版本。
新增範例分開通知帳務與 claim_version。寄送回條不改認領版本,接手或結案才改。甚至真人按得比接受回條落盤還快,也以認領為準;稍後到的通知紀錄只能補帳,不能把 human_claimed 改回等待通知。
測試所核對的投影可以縮成這樣,以下是欄位示例,不是實機結果:
{
"request_id": "synthetic-req-001",
"notification": {
"retry_key": "retry-case19-notif-001",
"status": "accepted",
"provider_id": "fake-line-ack-001"
},
"inbox": {
"state": "human_claimed",
"claim_version": 2
},
"evidence_scope": "SQLITE_OFFLINE_FAKE_SENDER"
}
這樣就能看出,通知帳務仍是接受狀態,業務交接已往前到認領;兩者不必共用同一個容易被覆蓋的狀態欄位。
第二個反例是兩個資料庫連線一起認領。原測試先呼叫第一人,再呼叫第二人,證明的是循序防重;新增測試用兩個執行緒、兩個連線同時競爭,檢查只有一筆條件更新成功。這仍是本機 SQLite 範圍,雲端交易要另驗。
第三個反例是通道接受後遺失回條。替身先保存接受紀錄再拋逾時;本地重試沿用原鍵,最後只有一筆替身接受紀錄。它驗的是重試協定,不是網路只會發出一次請求。
| 證據 | 本次狀態 | 能支持的主張 |
|---|---|---|
| 新增 SQLite 契約自測 | 十八項通過 | 版本、雙連線競爭、授權、持久化與不確定回條 |
| 本機示範 | 已執行,外部 API 零次 | 假傳送器與隔離收件匣可接續 |
| Day 19 同版 CI | Commit bfaa74a,9 條全綠 | SQLite 收件匣、outbox 與認領契約可重現 |
| 既有服務接線 | 待整合 | register_confirmed() 已定義接點,尚未接入既有 LINE 入口 |
| 真人端點與推播 | 未執行;移至 Day 22 | 外部通道推播與真人驗收待 Day 22 配合秘密與白名單補齊 |
| 線上部署狀態 | 線上服務尚未換上本篇收件匣 | 本次為隔離契約自測,既有線上服務未做變更 |
真人端驗收需要把收件匣接進既有 LINE 服務、設定推播用的秘密與接手者白名單,這些正好是 Day 22「權限、秘密與停止開關」的範圍;兩個 LINE 視窗的實機證據,我會在 Day 22 補齊。
手機驗收最好一次完成三個動作:接收端看到待辦,登入的測試志工按接手,原使用者再查一次進度。三張紀錄要能對回同一單號與同一版服務。若只有通知泡泡,驗收到的是可見送達;若只有後端 human_claimed,仍需要核對它是否真由授權者操作。這樣的證據分工,也讓未來展示時知道每個畫面能講到哪裡。
整合時還要從經 LINE 驗簽的事件取接手者身分,而不是相信 Postback 字串傳來的志工 ID。按鈕只提出認領請求,後端仍查授權、單號與版本。[5] 若是我自己操作測試接收端,就寫明是自測窗口,不包裝成地方志工團隊已投入服務。
今天的範圍不是客服後台,也沒有值班分派、升級規則或回覆時限保證。讀者先帶走一個可重跑的收件匣契約:原單不重建、通知可查回、接手有版本、查詢有權限。這些條件站穩,才值得接上真正的窗口。
Day 18 教我檢查判分器,今天則檢查回條背後的責任。通知 API 接受、手機看見、真人認領與服務完成,分別需要不同證據。要讓標題裡的「真的接到單」成立,最後一段真人驗收仍得真的走過,不能只把終端機畫面換成一張漂亮圖。
下一篇是 Day 20|模型設定、延遲與每項任務成本:品質先過關,再談快與省 。我會把同一套地方任務的品質、用量與時間放在同一張帳本裡,先辨認哪些是量到的結果,再討論快與省。
本篇新增 examples/day19/inbox_contract.py、test_inbox_contract.py。這是隔離補強範例,尚未替換既有 LINE 入口;整合前提見同目錄技術 README。
[1] Day 18:二十題地方契約評測。
[2] SQLite:交易與 BEGIN IMMEDIATE。
[3] Firestore:交易與批次寫入。
[4] LINE:重試失敗的 API 請求。
[5] LINE:驗證 Webhook 簽章。