diff --git a/docs/superpowers/specs/2026-06-23-notification-service-email-line-design.md b/docs/superpowers/specs/2026-06-23-notification-service-email-line-design.md new file mode 100644 index 0000000..58fd58a --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-notification-service-email-line-design.md @@ -0,0 +1,293 @@ +# Notification Service (Email + Line) — 設計文件 + +- **日期**:2026-06-23 +- **狀態**:已核可(待實作計畫) +- **作者**:Chris Chen + Claude +- **專案**:ROLAC(River Of Life Christian Church 教會管理系統) +- **前置文件**:[2026-06-20-line-notifications-design.md](2026-06-20-line-notifications-design.md)(Line 模組)、[../../NOTIFICATIONS.md](../../NOTIFICATIONS.md)(早期多通道願景) + +--- + +## 1. 背景與目標 + +需要一個**後端通知能力**,讓後端程式碼可以決定透過 **Email** 或 **Line** 發送訊息: + +- **Email**:正式內容(年度奉獻收據含 PDF、歡迎信、密碼重設等)。每位會友 `Member.Email` 已存在,無需綁定。 +- **Line**:非正式提醒,介於 Email(太正式)與 SMS(不適合)之間;可發給個人或群組。需先綁定取得 Line `userId`。 + +本次將 2026-06-20 已核可的 **Line 模組**完整實作,並**新增 Email 作為第二通道**,兩者共用 `NotificationLog` 稽核表。 + +### 決策摘要(brainstorming 結論) + +| 決策 | 選擇 | +|---|---| +| 範圍 | 完整 Line 模組 **+** Email 第二通道,一次完成 | +| 本階段平台 | **僅 API 端**(不做 Angular 前端;Line spec 的 UI 延後) | +| Email 傳輸 | **SMTP**(使用 **MailKit / MimeKit**) | +| 路由決策 | **後端程式碼決定**每次發送走 Email 或 Line | +| Email 內容 | Subject + HTML body + 可選附件;**呼叫端自行組好最終 body**(本階段不做範本引擎) | +| 服務切分 | **兩個對等服務**(非單一 facade):`IEmailService` + `ILineNotificationService` | + +### 為什麼 Email 不塞進 `IMessageChannel` + +Line spec 的 `IMessageChannel` 是聊天形狀(`PushToUserAsync(externalId, text)` / `ReplyAsync(replyToken, text)`),而 Email 有 subject、HTML、附件、收件地址、無 reply token、無群組概念。強行共用會造成糟糕的抽象。故 Email 走獨立的 `IEmailService`。 + +--- + +## 2. 架構總覽 + +``` + 後端程式碼(receipts / welcome / reminders ...) + │ 直接呼叫對應服務(後端決定通道) + ┌──────────────┴───────────────┐ + ▼ ▼ + IEmailService ILineNotificationService + (EmailService) (LineNotificationService) + MailKit / SMTP 收件人解析 + 稽核 + subject / HTML / 附件 │ + │ ▼ + │ IMessageChannel + │ (LineMessageChannel) ← 沿用核可 spec + │ Push / Reply REST + ▼ │ + └──────────► NotificationLog ◄──┘ (共用稽核表,channel 區分) + ▲ + │ + LineWebhookController(匿名、HMAC 驗簽)──► 綁定 + 群組註冊(多用 reply 省額度) + NotificationsController(admin [Authorize])──► 綁定碼 / 群組 / 歷史 / 手動發送 +``` + +- **兩個對等服務**,後端依用途自行呼叫。 +- 兩者皆寫 `NotificationLog`(`Channel` 欄位區分 `email` / `line`)。 +- Line 對內接收(webhook)與綁定流程沿用核可 spec。 + +--- + +## 3. 資料模型(新實體) + +Line 三表沿用核可 spec;`NotificationLog` 調整為同時服務兩通道。命名與軟刪除慣例對齊既有實體。 + +### `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`(共用:Email + Line) +每筆發送的稽核記錄。 + +| 欄位 | 型別 | 說明 | +|---|---|---| +| Id | int | PK | +| Channel | string | "line" / "email" | +| TargetType | string | "user" / "group" / "email" | +| TargetExternalId | string | Line id **或** email 地址 | +| Subject | string? | **新增**:Email 主旨(Line 為 null) | +| MemberId | int? FK | 由會友解析時填 | +| MessagingGroupId | int? FK | Line 群組發送時填 | +| Body | string | 訊息內容(Email 存 HTML body,過長時截斷至合理上限,例如 8000 字元,並標註截斷) | +| Status | string | "sent" / "failed" | +| Error | string? | 失敗原因 | +| SentByUserId | string | 發送者(JWT sub claim,使用 `?? "sub"` fallback;背景觸發時填 "system") | +| SentAt | DateTime | | + +- EF migration 以 `-c Release` / `--output` 建置(VS 鎖 `bin/Debug`)。 + +### `ScheduledNotification`(Phase 2 預留,本階段不建表) +Body、收件人規格、RunAtUtc、Recurrence、Status。 + +--- + +## 4. 服務介面與流程 + +### 4.1 `IEmailService`(獨立,MailKit/SMTP) + +```csharp +public interface IEmailService +{ + Task SendAsync(EmailMessage message, CancellationToken ct = default); +} + +public sealed record EmailMessage( + IReadOnlyList MemberIds, // 解析 Member.Email + IReadOnlyList Addresses, // 直接指定的收件地址 + string Subject, + string HtmlBody, // 呼叫端組好的最終 HTML + IReadOnlyList? Attachments = null, + string? SentByUserId = null); + +public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content); +``` + +**流程** +1. 解析 `MemberIds` → `Member.Email`(跳過 null/空白),與 `Addresses` 去重合併。 +2. 逐一以 MailKit 發送(subject + HTML + 附件)。 +3. 每位收件人寫一筆 `NotificationLog`(`channel=email`、`sent`/`failed`)。 +4. 回傳 `NotificationResult` 彙總;**單一收件人失敗不丟例外**,記錄後續行。 +5. SMTP 連線/驗證層級錯誤 → 寫 SystemLog,並把該批標記 failed。 + +**MailKit seam**:實際 `SmtpClient.Connect/Authenticate/Send` 封裝在薄介面(例如 `ISmtpDispatcher`)後,使 `EmailService` 的收件人解析、附件對應、log 寫入可單元測試(不需真實 SMTP server)。 + +### 4.2 `ILineNotificationService`(沿用核可 spec) + +```csharp +public interface ILineNotificationService +{ + Task SendLineAsync(string body, int[] memberIds, int[] groupIds, + string sentByUserId, CancellationToken ct = default); + Task GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default); +} +``` + +**`SendLineAsync` 流程**:解析收件人 → 已綁定會友 `ExternalId` + 啟用群組 `ExternalId`;逐一呼叫 `IMessageChannel`;每筆寫 `NotificationLog`(`channel=line`);回傳彙總。 + +### 4.3 共用回傳型別 + +```csharp +public sealed record NotificationResult( + int SentCount, int FailedCount, IReadOnlyList Failures); + +public sealed record NotificationFailure(string Target, string Error); +``` + +### 4.4 `IMessageChannel` / `LineMessageChannel`(沿用核可 spec) +- `PushToUserAsync(externalId, text)`、`PushToGroupAsync(externalId, text)`、`ReplyAsync(replyToken, text)`。 +- 封裝 Line REST;`HttpClient` 經 `IHttpClientFactory`;token 讀 config。回傳成功/失敗供上層寫 log。 + +### 4.5 `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 說明。重複綁定 → 更新既有 binding。 + - **join**:建 `MessagingGroup`(Name 待命名);reply 提示後台命名。 + - **leave**:對應 `MessagingGroup` 設 `IsActive=false`。 + +### 4.6 `NotificationsController`(admin `[Authorize]`) +本階段無前端,這些端點供管理員(Swagger)操作與測試發送: + +- `POST /api/notifications/members/{id}/line-binding-code` — 產生綁定碼。 +- `GET /api/notifications/groups` / `PUT .../groups/{id}` — 群組列表 / 改名 / 啟停用。 +- `GET /api/notifications/history` — `NotificationLog` 分頁(Email + Line)。 +- `POST /api/notifications/send-line` — 手動發 Line(body + memberIds[] + groupIds[])。 +- `POST /api/notifications/send-email` — 手動發 Email(subject + htmlBody + memberIds[] + addresses[],附件本階段以後端程式為主要用途,端點先支援無附件)。 + +> 註:真正的程式化發送(收據、歡迎信等)由其他後端程式直接呼叫 `IEmailService` / `ILineNotificationService`;上述 send 端點主要供無 UI 階段的手動測試與管理員臨時發送。 + +--- + +## 5. 設定(config)與 DI + +### 5.1 設定區段(secrets 走 user-secrets / 環境變數,勿入版控) + +```jsonc +"Smtp": { + "Host": "smtp.example.com", + "Port": 587, + "UseSsl": true, // STARTTLS + "User": "${SMTP_USER}", + "Password": "${SMTP_PASSWORD}", + "FromAddress": "noreply@rolac.org", + "FromName": "River of Life Christian Church" +}, +"Line": { + "ChannelAccessToken": "${LINE_CHANNEL_ACCESS_TOKEN}", + "ChannelSecret": "${LINE_CHANNEL_SECRET}" +} +``` + +### 5.2 DI([Program.cs](../../../API/ROLAC.API/Program.cs)) +- `builder.Services.Configure(config.GetSection("Smtp"))`、`Configure(...)`。 +- `AddScoped()`。 +- `AddScoped()`(MailKit seam)。 +- `AddScoped()`。 +- `AddScoped()` + `AddHttpClient`(Line REST)。 +- Webhook 為 server-to-server,CORS 不受影響。 + +--- + +## 6. 錯誤處理 + +- **Webhook 簽章不符** → 400 並記錄;合法事件一律快速回 200(避免 Line 重送)。 +- **Email 發送失敗**(無效地址、SMTP 拒絕)→ 該筆 `NotificationLog` `failed` + error;連線/驗證層級錯誤額外寫 SystemLog;不因單筆失敗中斷整批。 +- **Line 推播失敗**(封鎖、id 失效、**額度用罄**)→ `NotificationLog` `failed` + error,回報彙總。 +- **綁定碼**:過期 / 已使用 / 不存在 → reply 友善說明;重複綁定 → 更新既有 binding。 +- **發送者歸屬**:JWT sub claim 用 `?? "sub"` fallback;背景觸發填 "system"。 + +--- + +## 7. 測試(xUnit,`-c Release` / `--output` 建置) + +- **Email**:收件人解析(`MemberIds`→Email、跳過 null/空白、與 `Addresses` 去重);附件對應;單筆失敗不中斷整批;log 寫入與彙總(`ISmtpDispatcher` mock)。 +- **Line 簽章**:HMAC-SHA256 正/負案例。 +- **綁定碼**:有效 / 過期 / 已使用 / 不存在;重複綁定更新。 +- **收件人解析**:未綁定會友、停用群組被略過。 +- **Line payload 組裝**:mock `HttpMessageHandler`。 +- **Webhook 分派**:follow / message / join / leave 樣本 payload。 +- **彙總與 log**:成功 / 部分失敗。 + +--- + +## 8. 分階段範圍 + +### Phase 1(本次實作,僅 API 端) +- Email:`IEmailService` + MailKit SMTP + 附件 + `NotificationLog`。 +- Line:webhook 綁定(個人+群組)+ 手動立即發送 + 綁定碼 + 群組管理 + 歷史,皆透過 API/Swagger。 +- 兩服務共用 `NotificationLog`。 + +### Phase 2 +排程定時發送:`ScheduledNotification` + 背景 worker(`BackgroundService` 或 Hangfire/Quartz,實作計畫時決定)。 + +### Phase 3 +事件觸發(生日 / 活動報名確認 / 奉獻收據)整合既有模組;多通道擴充(PWA Web Push、WeChat);Email 範本引擎與雙語範本。 + +--- + +## 9. 使用者需先準備(非程式) + +- **SMTP**:取得寄件信箱主機 / port / 帳密 / 寄件地址(M365、Google Workspace 或主機 SMTP);設定寄件網域 SPF/DKIM 以利送達率。 +- **Line**:申請 **Line Official Account + Messaging API channel**,取得 **Channel access token** 與 **Channel secret**;nginx 將 `/api/line/webhook` 對外路由,於 Line 後台填入 webhook URL 並啟用。 +- 將 SMTP 與 Line secrets 放入 API 設定(user-secrets / 環境變數,勿入版控)。 + +--- + +## 10. 待解 / 未涵蓋(Out of scope) + +- Angular 前端(撰寫頁、歷史頁、綁定面板、群組管理頁)。 +- 排程與事件觸發發送。 +- Email 範本引擎與雙語範本(本階段呼叫端自行組 HTML)。 +- 通知偏好設定矩陣(會友自選通道)。 +- 圖文訊息、多語 Line 訊息。 +- 推播額度自動監控與告警。 +- 原生 App 推播 / PWA Web Push / SMS。