# 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 / 上架條件成熟後評估）。
