客服:社群客戶只查到自己的訂單
情境
ACME 把 Instagram 客服入口接到 TeamSync 聊天室。Ada、Bruno 與內部客服人員共用同一個 room,但外部客戶只能詢問自己建立或擁有的訂單;內部資料列與 Internal Margin 不得出現在客戶回答中。沒有權限的客戶可以用 passphrase 取得窄權限,或請分析員提出權限申請,由主管核准 own read。
資料模型
- Support Orders(部門 scope)
- Order Ref、Customer、Status、Internal Margin
- 系統擁有權 metadata
created_by_client將資料列對應到社群客戶 - 透過 department table 的 chatroom grant 分享到客服 room
- Support Tickets(聊天室 scope)
- Ticket Ref、Subject
- default permissions 關閉;passphrase 或 per-client grant 才能開門
- 客服 room
- internal audience:客服人員
- external audience:Instagram/LINE/Messenger client
Support department ── owns ──▶ Support Orders
│ chatroom grant: external + own
Instagram client ── support room ──┤
Internal staff ───── support room ──┘
Support Tickets ── passphrase / permission request ──▶ per-client grantNote
can_read: "own"對外部 client 會依created_by_client篩選,而不是依Customer顯示文字。不要用姓名欄位自行模擬資料列擁有權。
用一份 IaC 文件建起整條客服佇列
下面這份文件把工單佇列從頭建到尾:訂單簿與工單表以及兩者之間的 link 與 lookup、入口每次都會原樣留下的 inbound payload、讓工單不能草率結案的規則、兩支 SLA 時鐘、客服每天在用的檢視,以及把客服人員、客服主管與客戶三者分開的授權。把它貼進客服聊天室的 IaC 工作台,先 plan,再 apply。
一份 IaC 文件只作用在一個 scope,所以這份文件把兩張表都建在客服聊天室裡,再用 per-client grant 把資料列交給客戶。本頁步驟 2 描述的部門版本 —— 訂單簿放在 Support 部門、再用 chatroom principal 加 audience 分享進聊天室 —— 是同一組行套用到部門 scope 的結果;chatroom 授權在聊天室層級的表上會被拒絕。套用到正式環境前,請先改掉 $dept:Support、$user:sup.lead、客戶的 $smc:instagram:… token,以及通關密語。
{"kind":"header","version":1,"system":"acme-support","description":"ACME support: order book, ticket queue, SLA automation"}
{"kind":"table","ref":"orders","spec":{"name":"Support Orders","key":"order_ref","description":"The order book a customer may ask about"}}
{"kind":"column","table":"orders","ref":"order_ref","spec":{"name":"Order Ref","type":"string","required":true}}
{"kind":"column","table":"orders","ref":"customer","spec":{"name":"Customer","type":"string"}}
{"kind":"column","table":"orders","ref":"status","spec":{"name":"Status","type":"select","options":["open","shipped","refunded","closed"]}}
{"kind":"column","table":"orders","ref":"internal_margin","spec":{"name":"Internal Margin","type":"float","description":"Never published to an external audience"}}
{"kind":"table","ref":"tickets","spec":{"name":"Support Tickets","key":"ticket_ref"}}
{"kind":"column","table":"tickets","ref":"ticket_ref","spec":{"name":"Ticket Ref","type":"string","required":true}}
{"kind":"column","table":"tickets","ref":"subject","spec":{"name":"Subject","type":"string","required":true}}
{"kind":"column","table":"tickets","ref":"status","spec":{"name":"Status","type":"select","options":["new","triage","waiting","resolved","closed"]}}
{"kind":"column","table":"tickets","ref":"priority","spec":{"name":"Priority","type":"select","options":["low","normal","high","urgent"]}}
{"kind":"column","table":"tickets","ref":"opened_at","spec":{"name":"Opened At","type":"datetime"}}
{"kind":"column","table":"tickets","ref":"due_at","spec":{"name":"Due At","type":"datetime","description":"The SLA deadline both scheduled triggers fire from"}}
{"kind":"column","table":"tickets","ref":"resolution","spec":{"name":"Resolution","type":"text"}}
{"kind":"column","table":"tickets","ref":"sla_breached","spec":{"name":"SLA Breached","type":"boolean","default_value":false}}
{"kind":"column","table":"tickets","ref":"payload","spec":{"name":"Payload","type":"json","description":"The inbound webhook body or form submission, kept verbatim"}}
{"kind":"column","table":"tickets","ref":"order","spec":{"name":"Order","type":"link","target":"orders","cardinality":"one"}}
{"kind":"column","table":"tickets","ref":"order_status","spec":{"name":"Order Status","type":"lookup","link_field":"order","target_column":"status"}}
{"kind":"column","table":"orders","ref":"ticket_count","spec":{"name":"Ticket Count","type":"rollup","direction":"incoming","source":"tickets","match":{"order":"$self"},"aggregation":"count"}}
{"kind":"rule","table":"tickets","ref":"unique_ticket_ref","spec":{"type":"unique","name":"Ticket ref is unique","columns":["ticket_ref"]}}
{"kind":"rule","table":"tickets","ref":"ticket_ref_format","spec":{"type":"check","name":"Ticket refs look like TCK-1234","column":"ticket_ref","op":"matches","value":"^TCK-[0-9]{4}$"}}
{"kind":"rule","table":"tickets","ref":"status_flow","spec":{"type":"transition","name":"A ticket cannot jump the queue","column":"status","pairs":[[null,"new"],["new","triage"],["triage","waiting"],["waiting","triage"],["triage","resolved"],["waiting","resolved"],["resolved","triage"],["resolved","closed"]]}}
{"kind":"rule","table":"tickets","ref":"closing_needs_resolution","spec":{"type":"require","name":"A closing ticket carries a resolution","column":"resolution","when":[{"column":"status","op":"in","value":["resolved","closed"]}]}}
{"kind":"trigger","table":"tickets","ref":"sla_warning","spec":{"name":"Warn one hour before the SLA","on":"schedule","schedule":{"type":"date_column_reached","column":"due_at","offset_minutes":-60},"when":[{"column":"status","op":"in","value":["new","triage","waiting"]}],"actions":[{"type":"notify","message":"距離 SLA 剩一小時:$row.ticket_ref — $row.subject"}]}}
{"kind":"trigger","table":"tickets","ref":"sla_breach","spec":{"name":"Mark the SLA breached","on":"schedule","schedule":{"type":"date_column_reached","column":"due_at","offset_minutes":15},"when":[{"column":"status","op":"in","value":["new","triage","waiting"]}],"actions":[{"type":"update_record","target":"$row","data":{"sla_breached":true}},{"type":"notify","message":"SLA 已逾時:$row.ticket_ref — $row.subject"}]}}
{"kind":"view","table":"tickets","ref":"open_queue","spec":{"name":"Open queue","is_shared":true,"config":{"stored_filters":[{"column":"status","op":"in","value":["new","triage","waiting"]}],"sort_by":"due_at","sort_order":"asc","columns":["ticket_ref","subject","priority","status","due_at","order_status"]}}}
{"kind":"view","table":"tickets","ref":"breached_sla","spec":{"name":"Breached SLAs","is_shared":true,"config":{"stored_filters":[{"column":"sla_breached","op":"eq","value":true}],"sort_by":"due_at","sort_order":"asc","columns":["ticket_ref","subject","priority","status","due_at","resolution"]}}}
{"kind":"view","table":"orders","ref":"customer_orders","spec":{"name":"What a customer may see","is_shared":true,"config":{"sort_by":"order_ref","sort_order":"asc","columns":["order_ref","customer","status"]}}}
{"kind":"grant","table":"tickets","principal":{"type":"department","id":"$dept:Support"},"spec":{"can_read":"all","can_insert":true,"can_edit":"all","visible_columns":["ticket_ref","subject","status","priority","opened_at","due_at","resolution","sla_breached","order","order_status"]}}
{"kind":"grant","table":"tickets","principal":{"type":"user","id":"$user:sup.lead"},"spec":{"can_read":"all","can_insert":true,"can_edit":"all"}}
{"kind":"grant","table":"orders","principal":{"type":"client","id":"$smc:instagram:17841400000000001"},"spec":{"can_read":"own","can_insert":false,"can_edit":"none","visible_columns":["order_ref","customer","status"]}}
{"kind":"client_access","table":"tickets","spec":{"passphrase_enabled":true,"passphrase":"acme-kiwi-support-2026","passphrase_permissions":{"can_read":"own","can_insert":false,"can_edit":"none"}}}
{"kind":"record","table":"orders","data":{"order_ref":"ORD-ALPHA-01","customer":"Ada","status":"open","internal_margin":12.5},"on_drift":"skip"}
{"kind":"record","table":"tickets","data":{"ticket_ref":"TCK-1001","subject":"Where is my order?","status":"new","priority":"normal","order":["ORD-ALPHA-01"],"payload":{"source":"instagram","event_id":"ig_88213","body":{"text":"Where is my order?","attachments":[]}}},"on_drift":"skip"}值得多看兩眼的幾行:
tickets.payload是json欄位,原樣保存 inbound body,所以最後一行record可以直接把巢狀物件種進去。tickets.order_status是走orderlink 的lookup,客服在工單列上就看得到訂單真正的狀態,不必再查一次。tickets.ticket_ref_format是check規則、tickets.closing_needs_resolution是require規則 ——check從不否決空值,所以真正保證「工單進到resolved或closed一定帶著處理結果」的是這兩條的組合。tickets.status_flow是transition白名單;它刻意允許resolved → triage,讓被重啟的工單有一條合法的回頭路。tickets.sla_warning是排程觸發,offset_minutes: -60讓它在due_at之前一小時就發動;負值是 T-minus 提醒,而它用來排除已完成工單的when不是可選項 —— 少了它,在已有歷史資料的表上新增這條 trigger,第一次 tick 就會把所有逾期資料列一次全部觸發。tickets.sla_breach是同一組階梯的第二階,設在+15分鐘。一條 trigger 無法表達兩段提醒,因為冪等集合是以(trigger, record)為單位,第一次觸發之後這筆資料列對這條 trigger 就永遠關閉了。- 它的
update_recordaction 以系統權限執行,因為排程造成的執行沒有觸發者;也正是它讓breached_sla成為一份真正的佇列,而不是對日期做的搜尋。 tickets.open_queue以due_at遞增排序,所以最接近違約的那一列就在最上面。$dept:Support授權讓客服人員可以完整寫入,但visible_columns白名單裡沒有payload;$user:sup.lead授權完全不設visible_columns,所以主管是唯一讀得到原始 inbound body 的人。client授權是客戶的通道:can_read: "own"以created_by_client篩選,而它的visible_columns從不包含internal_margin,所以即使是客戶自己擁有的資料列,毛利也不會出現在回答裡。client_access用通關密語打開同一扇門,不需要管理員逐一設定;它是每張表唯一一筆、而且沒有state欄位的資源,要停用是把passphrase_enabled設成false,不是刪掉這一行。
json 欄位能做什麼、不能做什麼
payload 保存的是信封,不是索引。在 REST 上,json cell 只能對純量做等值比較 —— eq、neq、in、is_null、is_not_null —— 而且永遠不能排序,任何介面都一樣,包括儲存的檢視(把 json 放進 sort_by 的檢視在建立時會被接受,套用時才失敗)。它同樣不能當 rollup 或 lookup 的目標欄位、不能出現在 formula 裡、不能當 unique 規則的組成欄位、不能當 upsert 或審批的比對欄位、不能出現在 ACL row policy,CSV/XLSX 匯入在兩個方向上也都拒絕它。它更永遠不能當 IaC record 的自然鍵,這就是 tickets 要明寫 spec.key: "ticket_ref" 的原因。
因此,這條佇列需要篩選、排序或聚合的東西都各自有型別欄位 —— status、priority、due_at、sla_breached —— 在寫入時就抽出來。payload 是你想確認「客戶到底送了什麼」時才去讀的那一欄。完整的拒絕矩陣見 json 型別。
讓外部系統把工單寫進來
這份文件不會產生外部入口寫入時要用的憑證:callback token 是執行期的祕密,不是宣告式設定。管理者針對工單表鑄造一個 allowed_ops: "create" 的 token,把一次性的 secret 收好,入口之後就用穩定的 idempotency_key 把每個 inbound 事件 POST 到公開 callback 路由。寫入仍然會走過 ACL、規則、唯一性約束與 trigger,所以上面的 check 與 transition 對 webhook 的效力和對客服人員完全相同。
公開表單不必把欄位清單寫死。GET /public/module/custom_tables/callback/{token_id}/form-schema 會回傳表的顯示名稱與所有可寫入欄位的 {name, type},表單因此可以自己把欄位畫出來,不會每次有人改表就走樣。計算欄位 —— 這裡是 order_status 與 ticket_count —— 會被略過,因為沒有任何東西寫得進去;而 link、attachment、json、interval、principal、user 與 social_client 欄位會列出來,因為表單確實可以送這些值。這條路由不看 token 的 allowed_ops,也沒有流量限制,所以只能建立資料的 token 一樣描述得出整張表。
Apply 之後
這個佇列的進站那一半是 callback token,而它沒有對應的行類型:密鑰只顯示一次,所以無法在文件裡來回。請先 apply 這份文件,再用 REST 鑄造權杖並保存密鑰。之後公開表單就能從 form-schema 路由讀出自己的欄位,不必寫死。兩個呼叫都在用 REST 補完。
產品流程
1. 建立兩種 scope 的客服資料
在部門 scope 建立 Support Orders,在客服 room 建立 Support Tickets。以下為訂單表:
POST /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables
{
"name": "ACME Support Orders",
"schema_definition": {
"columns": [
{ "name": "Order Ref", "type": "string" },
{ "name": "Customer", "type": "string" },
{ "name": "Status", "type": "string" },
{ "name": "Internal Margin", "type": "float" }
]
}
}Tickets 建立後,以 defaultPermissions.update 關閉基線讀取:
PATCH /private/module/custom_tables/chatroom/22222222-2222-4222-8222-222222222222/tables/33333333-3333-4333-8333-333333333333/default-permissions
{ "can_read": "none", "can_insert": false, "can_edit": "none" }2. 把部門訂單分享給外部 audience
使用 department-only 的 chatroomPermissions.grant,把 Support Orders 放進客服 room 的 external 工具範圍,並將讀取層級限制為 own:
PUT /private/module/custom_tables/department/11111111-1111-4111-8111-111111111111/tables/44444444-4444-4444-8444-444444444444/chatroom-permissions/22222222-2222-4222-8222-222222222222
{
"audience": "external",
"can_read": "own",
"can_insert": false,
"can_edit": "none",
"visible_columns": [
"col_55555555_5555_4555_8555_555555555555",
"col_66666666_6666_4666_8666_666666666666",
"col_77777777_7777_4777_8777_777777777777"
]
}visible_columns 不含 Internal Margin,所以即使客戶擁有某筆資料列,該欄位也不會出現在工具結果或回答中。
3. 提供 passphrase 自助入口
對 chatroom-scope 的 Tickets 呼叫 clientAccess.update。Secret 只在管理介面輸入,不應寫進前端 bundle:
PATCH /private/module/custom_tables/chatroom/22222222-2222-4222-8222-222222222222/tables/33333333-3333-4333-8333-333333333333/client-access
{
"passphrase_enabled": true,
"passphrase": "acme-kiwi-support-2026",
"passphrase_permissions": {
"can_read": "own",
"can_insert": false,
"can_edit": "none"
}
}客戶在聊天室提供正確 passphrase 後,系統建立 granted_via: "passphrase" 的 per-client grant;錯誤 passphrase 不會建立 grant。既有的管理者手動 grant 不會被 passphrase 覆寫。
4. 讓主管核准自然語言權限申請
客戶可以說:「我需要查看自己的客服單,請替我申請 read-own。」分析員會建立 pending permission request。主管以 permissionRequests.list 取得 request ID,再核准:
GET /private/module/custom_tables/chatroom/22222222-2222-4222-8222-222222222222/tables/33333333-3333-4333-8333-333333333333/permission-requests?status=pendingPOST /private/module/custom_tables/chatroom/22222222-2222-4222-8222-222222222222/tables/33333333-3333-4333-8333-333333333333/permission-requests/88888888-8888-4888-8888-888888888888/approve
{ "can_read": "own", "can_insert": false, "can_edit": "none" }核准後可用 clientPermissions.list 看到 granted_via: "review";若主管拒絕,使用 permissionRequests.reject 並提供 review_note,不會建立 client grant。
使用者會看到什麼
Ada 問「列出我能看到的訂單」時只會得到 ORD-ALPHA-01、ORD-ALPHA-02;Bruno 只會得到 ORD-BRAVO-01。沒有 client 擁有權的 ORD-STAFF-99 與 Internal Margin 對兩人都不可見。內部客服可在同一個 room 使用 internal audience 的獨立 grant 查詢工作資料。passphrase 或主管核准後,新權限只套用到提出要求的 client。
變化與下一步
- 要理解 internal / external audience、per-client precedence 與
own,閱讀ACL 概念與有效權限。 - 要用 allowlist 隱藏 Internal Margin,閱讀欄位可見性。
- 要把分享、insight 載入與 agent 可見性串起來,閱讀grant/insight 指南。
試試看
先在 API Playground建立 external + own chatroom grant,再用兩個不同 client 身分讀取相同資料表;需要把管理流程文件化時,接著閱讀覆核流程概覽。