190 lines
8.8 KiB
Markdown
190 lines
8.8 KiB
Markdown
# 系統設計草案(Member Center)
|
||
|
||
日期:2026-01-30
|
||
|
||
## 1. 需求摘要(已確認)
|
||
1) 多個網站共用的會員登入中心(SSO)
|
||
2) 電子報訂閱管理(發送另建)
|
||
3) 未註冊會員可訂閱;註冊後沿用訂閱資料
|
||
4) 未註冊會員可取消訂閱(單一清單退訂)
|
||
5) 登入需支援 API 與 Redirect 兩種方式(OAuth2 + OIDC)
|
||
|
||
## 2. 架構原則
|
||
- OAuth2 + OIDC(Authorization Code + PKCE)
|
||
- 會員中心只管理 Email 與訂閱狀態
|
||
- Double Opt-in
|
||
- 各站自行設計 UI,主要走 API;少數狀況使用 redirect
|
||
- 多租戶為邏輯隔離,但會員資料跨站共享
|
||
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
|
||
- 訂閱狀態同步目前採 webhook(event payload);queue 為後續可選擴充
|
||
- PostgreSQL
|
||
- 實作:C# .NET Core + MVC + OpenIddict
|
||
|
||
## 3. 使用者故事(精簡)
|
||
- 訪客:在任一站點未登入狀態下輸入 Email 訂閱電子報
|
||
- 訪客:在信件中點擊連結完成 double opt-in
|
||
- 訪客:點擊取消訂閱連結即可退訂(單一清單)
|
||
- 會員:在任一站點登入後,其他站點可無痛登入(SSO)
|
||
- 站點後台:可管理站點資訊、訂閱清單、會員基本資料
|
||
|
||
## 4. 核心模組
|
||
- Identity Service:註冊、登入、密碼重設、Email 驗證
|
||
- OAuth2/OIDC Service:授權流程、token 發放、ID Token
|
||
- Subscription Service:訂閱/退訂/偏好管理
|
||
- Admin Console:租戶與清單管理
|
||
- Mailer Integration:驗證信/退訂信/確認信的發送介面(外部系統)
|
||
- Event Publisher:訂閱事件發佈(供發信系統或數據系統消費)
|
||
|
||
## 5. 資料模型(概念)
|
||
- tenants
|
||
- id, name, domains, status, created_at
|
||
- users (ASP.NET Core Identity)
|
||
- id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at
|
||
- roles / user_roles (Identity)
|
||
- id, name, created_at
|
||
- OpenIddictApplications
|
||
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|send_api|webhook_outbound|platform_service`)
|
||
- OpenIddictAuthorizations
|
||
- id, application_id, status, subject, type, scopes
|
||
- OpenIddictTokens
|
||
- id, application_id, authorization_id, subject, type, status, expiration_date
|
||
- OpenIddictScopes
|
||
- id, name, display_name, resources
|
||
- newsletter_lists
|
||
- id, tenant_id, name, status, created_at
|
||
- newsletter_subscriptions
|
||
- id, list_id, email, user_id (nullable), status, preferences, created_at
|
||
- email_blacklist
|
||
- id, email, reason, blacklisted_at, blacklisted_by
|
||
- email_verifications
|
||
- id, email, tenant_id, token_hash, purpose, expires_at, consumed_at
|
||
- unsubscribe_tokens
|
||
- id, subscription_id, token_hash, expires_at, consumed_at
|
||
- audit_logs
|
||
- id, actor_type, actor_id, action, payload, created_at
|
||
- system_flags
|
||
- id, key, value, updated_at
|
||
|
||
關聯說明:
|
||
- newsletter_subscriptions.email 與 users.email 維持唯一性關聯
|
||
- 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id
|
||
- 單一清單退訂:unsubscribe token 綁定 subscription_id
|
||
- blacklist 記錄於 email_blacklist(全租戶共用)
|
||
|
||
## 6. 核心流程
|
||
|
||
### 6.1 OAuth2/OIDC Redirect 登入(Authorization Code + PKCE)
|
||
1) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `scope=openid email`
|
||
2) 使用者於會員中心登入
|
||
3) 成功後導回 `redirect_uri` 並附 `code`
|
||
4) 站點以 `code` + `code_verifier` 向 `/oauth/token` 換取 token + `id_token`
|
||
|
||
### 6.2 OAuth2 API 使用(站點自行 UI)
|
||
1) 站點以 API 驗證使用者登入(會員中心提供 login API)
|
||
2) 成功後取得 token(含 ID Token 可選)
|
||
3) 站點以 access_token 呼叫其他會員中心 API
|
||
|
||
### 6.3 未登入狀態的訂閱流程(在獨立平台)
|
||
1) 使用者在各站點輸入 Email 並選擇訂閱清單
|
||
2) 站點呼叫 `POST /newsletter/subscribe`
|
||
3) 會員中心建立 `pending` 訂閱並發送驗證信(透過外部發信系統)
|
||
4) 使用者點擊信件連結 `/newsletter/confirm?token=...`
|
||
5) 訂閱狀態改為 `active`
|
||
6) 會員中心發出事件 `subscription.activated` 到 event/queue
|
||
|
||
### 6.4 未登入退訂(單一清單)
|
||
1) 信件提供「一鍵退訂」連結 `/newsletter/unsubscribe?token=...`
|
||
2) 驗證 token 後將該訂閱標記為 `unsubscribed`
|
||
3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue
|
||
|
||
### 6.4b Send Engine 發信前申請 One-Click Token
|
||
1) Send Engine 依收件者呼叫 `POST /newsletter/one-click-unsubscribe-token`,或批次呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
|
||
2) body 帶 `tenant_id + list_id + subscriber_id`(批次版為 `subscriber_ids[]`)
|
||
3) 會員中心簽發 token(與手動退訂 token 分離 purpose)
|
||
4) Send Engine 將 token 寫入 `List-Unsubscribe` 連結
|
||
|
||
### 6.6 Send Engine 事件同步(Member Center → Send Engine)
|
||
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`)
|
||
2) 以 webhook 推送至 Send Engine(簽章與重放防護)
|
||
3) `X-Client-Id` 使用 Send Engine `auth_clients.id`(可按 tenant 做設定覆蓋)
|
||
4) Send Engine 驗證 tenant scope,更新本地名單快照
|
||
|
||
### 6.7 Send Engine 退信/黑名單回寫(選用)
|
||
1) Send Engine 依事件類型決定回寫時機與原因碼:
|
||
2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:Member Center 取消該 email 所有訂閱(跨租戶)並加入黑名單
|
||
3) `complaint`:Member Center 僅取消該筆訂閱,不加入黑名單
|
||
4) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證
|
||
5) 回寫授權可用:
|
||
- tenant client scope:`newsletter:events.write`
|
||
- platform client scope:`newsletter:events.write.global`(SES 聚合事件)
|
||
|
||
### 6.5 註冊後銜接
|
||
1) 使用者完成註冊
|
||
2) 系統搜尋 `newsletter_subscriptions.email`
|
||
3) 將 `user_id` 補上並保留偏好
|
||
4) 可選:發出事件 `subscription.linked_to_user`
|
||
|
||
## 7. API 介面(草案)
|
||
- GET `/oauth/authorize`
|
||
- POST `/oauth/token`
|
||
- GET `/.well-known/openid-configuration`
|
||
- POST `/auth/login` (API-only login)
|
||
- POST `/auth/refresh`
|
||
- POST `/newsletter/subscribe`
|
||
- GET `/newsletter/confirm`
|
||
- POST `/newsletter/unsubscribe`
|
||
- POST `/newsletter/unsubscribe-token`
|
||
- POST `/newsletter/one-click-unsubscribe-token`
|
||
- POST `/newsletter/one-click-unsubscribe-tokens`
|
||
- GET `/newsletter/preferences`
|
||
- POST `/newsletter/preferences`
|
||
- POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫)
|
||
- POST `/webhooks/lists/full-sync`(Send Engine 端點,Member Center 呼叫)
|
||
- POST `/subscriptions/disable`(Member Center 端點,Send Engine 呼叫)
|
||
- POST `/integrations/send-engine/webhook-clients/upsert`(Member Center 端點,Send Engine 呼叫)
|
||
|
||
## 7.1 進度狀態(2026-02)
|
||
### API
|
||
- `GET /newsletter/subscriptions?list_id=...`:已實作
|
||
- `POST /webhooks/subscriptions`:已實作(Member Center 發送)
|
||
- `POST /subscriptions/disable`:已實作
|
||
- `POST /integrations/send-engine/webhook-clients/upsert`:已實作
|
||
- `POST /webhooks/lists/full-sync`:尚未實作(保留規格)
|
||
|
||
### Auth / Scope
|
||
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
||
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service`(禁止混用)
|
||
- 新增 scope:`newsletter:list.read`、`newsletter:send.write`、`newsletter:send.read`、`newsletter:events.read`
|
||
- 新增 scope:`newsletter:events.write`
|
||
- 新增 scope:`newsletter:events.write.global`
|
||
- JWT Access Token 已改為 JWS(`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽
|
||
|
||
### 租戶端取 Token(Client Credentials)
|
||
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
||
- token 內含 `tenant_id` 與 scope
|
||
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
||
- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫
|
||
|
||
## 7.2 尚未完成(待辦)
|
||
- `POST /webhooks/lists/full-sync`:Member Center 端尚未發送此事件(僅保留契約)
|
||
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)尚未在註冊流程落地
|
||
- `subscription.linked_to_user` 事件尚未發送
|
||
- 安全設定頁(access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
|
||
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
|
||
|
||
## 8. 安全與合規
|
||
- 密碼強度與防暴力破解(rate limit + lockout)
|
||
- Token rotation + refresh token revoke
|
||
- Redirect URI 白名單 + PKCE
|
||
- Double opt-in(可配置)
|
||
- Audit log
|
||
- GDPR/CCPA:資料匯出與刪除(規劃中)
|
||
|
||
## 9. 其他文件
|
||
- `docs/UI.md`
|
||
- `docs/USE_CASES.md`
|
||
- `docs/FLOWS.md`
|
||
- `docs/OPENAPI.md`
|
||
- `docs/SCHEMA.sql`
|
||
- `docs/TECH_STACK.md`
|