# Line 通知/提醒模組 — 設計文件 - **日期**:2026-06-20 - **狀態**:已核可(待實作計畫) - **作者**:Chris Chen + Claude - **專案**:ROLAC(River Of Life Christian Church 教會管理系統) --- ## 1. 背景與目標 教會需要一個發送「提醒」的管道,介於 Email(太正式)與簡訊 SMS(不適合)之間。 需求是:**有時發到特定群組、有時發給個人**。 決策摘要(brainstorming 結論): - **平台**:第一階段做 **Line**(Arcadia 台裔會友圈使用率最高,Messaging API 文件成熟,個人+群組皆支援)。 - **原生 Capacitor App 推播延後**:教會 501(c)(3) 尚未核准、App Store 上架對 50–100 人規模成本過高;之後再評估 PWA Web Push 或原生推播。 - **綁定**:個人與群組**兩者都要**。 - **觸發**:手動即時、排程定時、事件觸發**三者都要**,但**分階段**實作(先手動)。 - **多通道未來性**:發送層抽 `IMessageChannel` 介面,未來 PWA/WeChat 各加一個實作即可,本階段不過度建置(YAGNI)。 ### 為什麼不是其他方案 - **LINE Notify**:已於 2025 年停止服務,不可用。 - **WhatsApp**:Business API 不支援程式化發送到「群組」,與「發特定群組」需求衝突。 - **WeChat**:個人推播 48 小時視窗限制嚴、群組幾乎不可行;之後通道再評估。 - **第三方/no-code(Zapier 等)**:資料外流、難做精準個人綁定。 - **通用通知平台一次做好**:對本規模過度投資。 --- ## 2. 關鍵技術現實(限制條件) 1. **Bot 無法主動亂發訊息**:要發給個人,必須先取得對方的 Line `userId`,而此 id 只能在對方**加官方帳號好友 / 對 bot 說話 / 把 bot 拉進群**時,透過 webhook 取得。無法手動輸入。 2. **Webhook 需要公開 HTTPS endpoint**:生產環境 nginx 終止 TLS、API 在後(`UseForwardedHeaders` 已設定),故 webhook 走 `https://<網域>/api/line/webhook`,**無需新基礎建設**,只需 nginx 加路由。 3. **推播額度**:Line 官方帳號免費方案每月有則數上限(數百則)。**reply(對方剛互動的視窗內回覆)免費,push(主動推播)才計額度**。綁定流程走 reply,省額度。事件觸發大量發送時需留意額度。 4. **Line 要求 webhook 快速回 200**:非 200 會觸發重送,故事件處理要快速 ACK、重活非同步化或設計為冪等。 --- ## 3. 架構總覽 新增 **Messaging(通知)模組**。 ``` Angular (UserPortal) Line Platform ├─ 撰寫+發送頁 ──┐ │ ├─ 發送歷史頁 │ HTTPS (JWT) │ webhook (signed) ├─ 會友綁定面板 │ │ └─ 群組管理頁 ───┤ ▼ ▼ LineWebhookController (匿名, 驗簽) NotificationsController ──┐ │ follow/message/join/leave (admin [Authorize]) │ ▼ ├──► NotificationService ──► IMessageChannel │ (收件人解析 + 稽核) └─ LineMessageChannel │ (Push / Reply REST) ▼ AppDbContext (PostgreSQL) ``` - **對外推播**:`NotificationService` → `IMessageChannel.PushToUser/PushToGroup` → Line Push API。 - **對內接收**:`LineWebhookController` 驗簽後分派事件,處理綁定與群組註冊(多用 reply,省額度)。 --- ## 4. 資料模型(新實體) 全部繼承既有 `SoftDeleteEntity` 慣例(除非另註);命名與欄位對齊風格沿用現有實體。 ### `MemberChannelBinding` 會友與通道帳號的綁定(採獨立表,未來多通道不需改 `Member`)。 | 欄位 | 型別 | 說明 | |---|---|---| | Id | int | PK | | MemberId | int FK → Member | | | Channel | string | "line"(未來 "wechat"/"webpush") | | ExternalId | string | Line userId | | BoundAt | DateTime | 綁定時間 | - 唯一索引:`(MemberId, Channel)`、`(Channel, ExternalId)`。 ### `LineBindingCode` 短效綁定碼(會友在 Line 輸入此碼以完成綁定)。 | 欄位 | 型別 | 說明 | |---|---|---| | Id | int | PK | | Code | string | 短碼(避免易混淆字元) | | MemberId | int FK → Member | | | ExpiresAt | DateTime | 過期時間(建議 15 分鐘) | | ConsumedAt | DateTime? | 已使用時間(null = 未用) | ### `MessagingGroup` bot 被拉進的 Line 群組。 | 欄位 | 型別 | 說明 | |---|---|---| | Id | int | PK | | Channel | string | "line" | | ExternalId | string | Line groupId | | Name | string? | 管理員命名(join 時先空白待命名) | | IsActive | bool | leave 事件時設 false | | RegisteredAt | DateTime | | - 唯一索引:`(Channel, ExternalId)`。 ### `NotificationLog` 每筆發送的稽核記錄。 | 欄位 | 型別 | 說明 | |---|---|---| | Id | int | PK | | Channel | string | "line" | | TargetType | string | "user" / "group" | | TargetExternalId | string | 實際送達的 Line id | | MemberId | int? FK | 個人發送時填 | | MessagingGroupId | int? FK | 群組發送時填 | | Body | string | 訊息內容 | | Status | string | "sent" / "failed" | | Error | string? | 失敗原因 | | SentByUserId | string | 發送者(JWT sub claim,注意 `?? "sub"` fallback) | | SentAt | DateTime | | ### `ScheduledNotification`(Phase 2 預留,本階段不建表) Body、收件人規格(memberIds/groupIds 或分群)、RunAtUtc、Recurrence、Status。 --- ## 5. 元件與流程 ### `IMessageChannel` / `LineMessageChannel` - `PushToUserAsync(externalId, text)` - `PushToGroupAsync(externalId, text)` - `ReplyAsync(replyToken, text)` - 封裝 Line REST 呼叫;`HttpClient` 經 `IHttpClientFactory`;token 讀 config。 - 回傳結果含成功/失敗,供 `NotificationService` 寫 log。 ### `LineWebhookController`(路由 `/api/line/webhook`,匿名) 1. 讀**原始 body**,以 `Line:ChannelSecret` 計算 HMAC-SHA256,與 `X-Line-Signature` 比對;不符回 400。 2. 快速回 200,事件分派: - **follow**(加好友):記 userId(暫存或直接回覆);reply 綁定說明(請輸入綁定碼)。 - **message**(文字):比對有效(未過期、未使用)`LineBindingCode` → 建立 `MemberChannelBinding`、標記 code 已用、reply 綁定成功;無對應碼則 reply 說明文字。 - **join**(拉進群):建一筆 `MessagingGroup`(ExternalId=groupId,Name 待命名);reply 提示管理員到後台命名。 - **leave**:對應 `MessagingGroup` 設 `IsActive=false`。 ### `NotificationService` - `SendNowAsync(body, memberIds[], groupIds[], sentByUserId)`: 1. 解析收件人 → 已綁定會友的 `ExternalId` + 啟用群組的 `ExternalId`。 2. 對每個目標呼叫對應 `IMessageChannel` 方法。 3. 每筆寫 `NotificationLog`(成功/失敗)。 4. 回傳彙總(成功數、失敗數、失敗明細)。 - `GenerateBindingCodeAsync(memberId)`:產生短效碼。 - 收件人/群組查詢、綁定列表/解除。 ### `NotificationsController`(admin `[Authorize]`) - `POST /api/notifications/send` — 立即發送(body + memberIds[] + groupIds[])。 - `GET /api/notifications/recipients` — 可選收件人(已綁定會友 + 啟用群組)。 - `GET /api/notifications/history` — `NotificationLog` 分頁。 - `POST /api/notifications/members/{id}/line-binding-code` — 產生綁定碼。 - `GET /api/notifications/groups` / `PUT .../groups/{id}` — 群組列表/改名/啟停用。 ### 設定(config) - `Line:ChannelAccessToken`、`Line:ChannelSecret`(user-secrets / appsettings,勿入版控)。 - CORS 不受影響(webhook 為 server-to-server)。 - DI:於 [Program.cs](API/ROLAC.API/Program.cs) 以 scoped 註冊 `IMessageChannel→LineMessageChannel`、`INotificationService→NotificationService`;`AddHttpClient`。 --- ## 6. 前端(Angular) 依慣例掛進 **UserPortalComponent** 側欄(`financeNavItems` 模式 + `getPageTitle`),新 `notifications` feature: - **撰寫+發送頁**:文字框 + 收件人多選(已綁定會友 + 群組)+ 送出。Kendo 元件、Tailwind 排版(`grid grid-cols-1 md:grid-cols-2`,preflight off)。Kendo DropdownList/MultiSelect 記得 `[valuePrimitive]="true"`。送出後顯示成功/失敗彙總。 - **發送歷史頁**:Kendo 表格列 `NotificationLog`;列點選看明細,動作放右鍵 context menu(依慣例)。 - **會友 Line 綁定面板**:會員明細頁加區塊,顯示綁定狀態;「產生綁定碼」按鈕 + 操作說明(加官方帳號 → 輸入此碼)。 - **群組管理頁**:列出已註冊 Line 群組,可命名 / 啟停用。 - 新增 `OfferingEntry` 之外的對應 API service(`notification-api.service.ts`)。 --- ## 7. 錯誤處理 - **Webhook 簽章不符** → 400 並記錄;合法事件一律快速回 200(避免 Line 重送)。 - **推播失敗**(對方封鎖官方帳號、id 失效、**額度用罄**)→ 寫入 `NotificationLog`(status=failed + error),並把失敗筆數回報管理員 UI。 - **綁定碼**:過期 / 已使用 / 不存在 → reply 友善說明;重複綁定 → 更新既有 binding 而非報錯。 - **額度**:之後階段可加每月推播計數器;本階段先以 log 觀察。 --- ## 8. 測試(沿用既有 xUnit 慣例,`-c Release`/`--output` 建置) - 簽章驗證(HMAC-SHA256 正/負案例)。 - 綁定碼比對(有效 / 過期 / 已使用 / 不存在)。 - 收件人解析(含未綁定會友被略過、停用群組被略過)。 - Line payload 組裝(mock `HttpClient`/`HttpMessageHandler`)。 - Webhook 事件分派(follow / message / join / leave 樣本 payload)。 - `NotificationService.SendNowAsync` 成功/部分失敗的彙總與 log 寫入。 --- ## 9. 分階段範圍 ### Phase 1(本次實作) Line 通道 + webhook 綁定(個人+群組)+ 手動立即發送 + 發送歷史 + 會友綁定 UI + 群組管理 UI。 ### Phase 2 排程定時發送:`ScheduledNotification` 實體 + 背景 worker(`BackgroundService` 或 Hangfire/Quartz,實作計畫時決定)。 ### Phase 3 事件觸發(生日 / 活動報名確認 / 奉獻收據)整合既有模組;多通道擴充(PWA Web Push、WeChat)各加一個 `IMessageChannel` 實作。 --- ## 10. 使用者需先準備(非程式) - 申請 **Line Official Account + Messaging API channel**,取得 **Channel access token** 與 **Channel secret**。 - 在 nginx 設定,把 `/api/line/webhook` 對外路由到 API;於 Line 後台填入 webhook URL 並啟用。 - 將 token/secret 放入 API 設定(user-secrets / appsettings,勿入版控)。 --- ## 11. 待解 / 未涵蓋(Out of scope,留待後續) - 訊息範本與圖文訊息(Phase 1 僅純文字)。 - 多語訊息(會友 `LanguagePreference` 可於後續用於範本選擇)。 - 推播額度自動監控與告警。 - 原生 App 推播 / PWA Web Push(待 501c3 / 上架條件成熟後評估)。