Initial project
This commit is contained in:
commit
5d7b2e79ea
73
.gitignore
vendored
Normal file
73
.gitignore
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
# Build results
|
||||
bin/
|
||||
obj/
|
||||
[Bb]uild/
|
||||
[Bb]uilds/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.vcxproj.user
|
||||
*.vcxproj.filters
|
||||
*.vcxproj.user
|
||||
|
||||
# Rider / JetBrains
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Visual Studio Code local history
|
||||
.history/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of PackageReference
|
||||
# Uncomment if using packages.config
|
||||
#packages/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# User-specific files
|
||||
*.userprefs
|
||||
|
||||
# Resharper / DotCover
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
*.trx
|
||||
|
||||
# Coverage
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
coverage/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# Secrets
|
||||
secrets.json
|
||||
appsettings.*.json
|
||||
!appsettings.json
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Others
|
||||
*.swp
|
||||
*.tmp
|
||||
51
README.md
Normal file
51
README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# 會員平台(Member Center)
|
||||
|
||||
此專案為多網站共用的會員登入中心與電子報訂閱管理平台。文件統一放在 `docs/`。
|
||||
|
||||
## 目標(MVP)
|
||||
- 多個網站共用的會員登入中心(SSO)
|
||||
- OAuth2 + OIDC 登入(API 與 Redirect 兩種方式)
|
||||
- 電子報訂閱管理(含未註冊訂閱)
|
||||
- 未註冊訂閱者可自行取消訂閱
|
||||
- 未註冊訂閱者未來註冊可沿用訂閱資料
|
||||
|
||||
## 既定條件
|
||||
- OAuth2 + OIDC
|
||||
- 會員中心只負責 Email/訂閱管理,發送系統另建
|
||||
- 訂閱採 double opt-in
|
||||
- 各站各自設計 UI,主要走 API;少數狀況使用 redirect
|
||||
- 會員資料跨站共享(邏輯隔離)
|
||||
- 訂閱事件同步採 event/queue
|
||||
- DB:PostgreSQL
|
||||
- 開發:C# .NET Core + MVC + OpenIddict
|
||||
|
||||
## 範圍(第一階段)
|
||||
- 多租戶(站點)概念與基本設定
|
||||
- 會員與訂閱者 Email 身分識別
|
||||
- OAuth2/OIDC 授權(Authorization Code + PKCE)
|
||||
- 訂閱/取消訂閱流程(含未註冊)
|
||||
- 基本管理後台(核心 CRUD)
|
||||
|
||||
## 非目標(暫不處理)
|
||||
- 第三方社群登入
|
||||
- MFA
|
||||
- 行銷自動化或行為追蹤
|
||||
- 金流/付費會員
|
||||
|
||||
## 文件
|
||||
- `docs/DESIGN.md`
|
||||
- `docs/UI.md`
|
||||
- `docs/USE_CASES.md`
|
||||
- `docs/FLOWS.md`
|
||||
- `docs/OPENAPI.md`
|
||||
- `docs/openapi.yaml`
|
||||
- `docs/SCHEMA.sql`
|
||||
- `docs/TECH_STACK.md`
|
||||
- `docs/SEED.sql`
|
||||
- `docs/INSTALL.md`
|
||||
|
||||
## 待確認事項
|
||||
- OAuth2 scopes 與最小 claims(email/profile)
|
||||
- 事件系統選擇(Kafka/RabbitMQ/SNS+SQS)
|
||||
- 取消訂閱的 UI 形式(純一鍵或提供偏好)
|
||||
- GDPR/CCPA 資料匯出與刪除是否納入第一期
|
||||
124
docs/DESIGN.md
Normal file
124
docs/DESIGN.md
Normal file
@ -0,0 +1,124 @@
|
||||
# 系統設計草案(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
|
||||
- 多租戶為邏輯隔離,但會員資料跨站共享
|
||||
- 訂閱狀態同步採 event/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
|
||||
- id, email, password_hash, email_verified_at, status, created_at
|
||||
- oauth_clients
|
||||
- id, tenant_id, name, redirect_uris, client_type, created_at
|
||||
- oauth_codes
|
||||
- id, client_id, user_id, code_hash, code_challenge, expires_at, consumed_at
|
||||
- oauth_tokens
|
||||
- id, user_id, client_id, access_token_hash, refresh_token_hash, expires_at, revoked_at
|
||||
- oidc_id_tokens
|
||||
- id, user_id, client_id, id_token_hash, expires_at
|
||||
- newsletter_lists
|
||||
- id, tenant_id, name, status, created_at
|
||||
- newsletter_subscriptions
|
||||
- id, list_id, tenant_id, email, user_id (nullable), status, preferences, created_at
|
||||
- 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
|
||||
|
||||
關聯說明:
|
||||
- newsletter_subscriptions.email 與 users.email 維持唯一性關聯
|
||||
- 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id
|
||||
- 單一清單退訂:unsubscribe token 綁定 subscription_id
|
||||
|
||||
## 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.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`
|
||||
- GET `/newsletter/preferences`
|
||||
- POST `/newsletter/preferences`
|
||||
|
||||
## 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`
|
||||
47
docs/FLOWS.md
Normal file
47
docs/FLOWS.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Flows
|
||||
|
||||
流程以「API 自建 UI」與「會員中心統一 UI」兩種模式描述。
|
||||
|
||||
## F-01 註冊會員
|
||||
- [API] 站點送出 `POST /auth/register`(待補 API)
|
||||
- [API] 會員中心建立 user、寄送驗證信
|
||||
- [UI] 導向會員中心註冊頁完成註冊
|
||||
- [UI] 會員中心寄送驗證信
|
||||
|
||||
## F-02 登入(OAuth2 + OIDC)
|
||||
- [API] 站點送出 `POST /auth/login` 取得 access_token + id_token
|
||||
- [API] 站點建立自身 session
|
||||
- [UI] 導向 `/oauth/authorize` 完成授權碼流程
|
||||
- [UI] 站點用 code 換 token + id_token
|
||||
|
||||
## F-03 忘記密碼 / 重設密碼
|
||||
- [API] 站點送出 `POST /auth/password/forgot`
|
||||
- [UI] 會員中心頁提交 email 並發送重設信
|
||||
- [API/UI] 使用 token 進入重設密碼頁
|
||||
|
||||
## F-04 訂閱電子報(未登入)
|
||||
- [API] 站點送出 `POST /newsletter/subscribe`
|
||||
- [API] 會員中心建立 pending 訂閱並發送驗證信
|
||||
- [UI] 使用者點擊驗證信連結 `/newsletter/confirm?token=...`
|
||||
- [UI] 訂閱改為 active,發出 event `subscription.activated`
|
||||
|
||||
## F-05 取消訂閱(單一清單)
|
||||
- [UI] 使用者點擊退訂連結 `/newsletter/unsubscribe?token=...`
|
||||
- [UI] 訂閱狀態改為 unsubscribed
|
||||
- [API] 發出 event `subscription.unsubscribed`
|
||||
|
||||
## F-06 訂閱偏好管理(登入後)
|
||||
- [API] 站點讀取 `/newsletter/preferences`
|
||||
- [API] 站點更新 `/newsletter/preferences`
|
||||
- [UI] 會員中心提供偏好頁(可選)
|
||||
|
||||
## F-07 會員資料查看
|
||||
- [API] 站點讀取 `/user/profile`
|
||||
- [UI] 會員中心提供個人資料頁
|
||||
|
||||
## F-08 管理者管理租戶/清單/Client
|
||||
- [UI] 會員中心管理後台進行 CRUD
|
||||
|
||||
## F-09 訂閱與會員綁定
|
||||
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
|
||||
- [API] 發送事件 `subscription.linked_to_user`
|
||||
16
docs/INSTALL.md
Normal file
16
docs/INSTALL.md
Normal file
@ -0,0 +1,16 @@
|
||||
# 安裝與初始化(Schema + Seed)
|
||||
|
||||
提供一個簡單安裝流程,將 DB schema 與種子資料一起初始化。
|
||||
|
||||
## 內容
|
||||
- Schema:`docs/SCHEMA.sql`
|
||||
- Seed:`docs/SEED.sql`
|
||||
|
||||
## 建議流程(範例)
|
||||
1) 建立資料庫
|
||||
2) 執行 `SCHEMA.sql`
|
||||
3) 執行 `SEED.sql`(包含預設 admin 角色與初始帳號)
|
||||
|
||||
## 注意事項
|
||||
- `docs/SEED.sql` 內 `admin@example.com` 與 `<PASSWORD_HASH>` 為預設值,請部署前替換
|
||||
- 若使用 ASP.NET Core Identity,請使用對應的密碼雜湊格式
|
||||
18
docs/OPENAPI.md
Normal file
18
docs/OPENAPI.md
Normal file
@ -0,0 +1,18 @@
|
||||
# OpenAPI 草案(完整)
|
||||
|
||||
已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
|
||||
|
||||
## 版本
|
||||
- OpenAPI: 3.1.0
|
||||
- 檔案:`docs/openapi.yaml`
|
||||
|
||||
## 核心資源
|
||||
- OAuth2/OIDC:授權、token、discovery、JWKS
|
||||
- Auth:註冊、登入、刷新、登出、忘記/重設密碼、Email 驗證
|
||||
- User:個人資料
|
||||
- Newsletter:訂閱/確認/退訂/偏好
|
||||
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
||||
|
||||
## Security Schemes
|
||||
- OAuth2 (Authorization Code + PKCE)
|
||||
- Bearer JWT(API 使用)
|
||||
144
docs/SCHEMA.sql
Normal file
144
docs/SCHEMA.sql
Normal file
@ -0,0 +1,144 @@
|
||||
-- PostgreSQL schema draft (MVP)
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
domains TEXT[] NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_clients (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
name TEXT NOT NULL,
|
||||
redirect_uris TEXT[] NOT NULL DEFAULT '{}',
|
||||
client_type TEXT NOT NULL DEFAULT 'confidential',
|
||||
client_secret_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE oauth_clients
|
||||
ADD CONSTRAINT chk_oauth_clients_secret
|
||||
CHECK (
|
||||
(client_type = 'confidential' AND client_secret_hash IS NOT NULL)
|
||||
OR (client_type <> 'confidential')
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_codes (
|
||||
id UUID PRIMARY KEY,
|
||||
client_id UUID NOT NULL REFERENCES oauth_clients(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
code_hash TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL DEFAULT 'S256',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE oauth_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
client_id UUID NOT NULL REFERENCES oauth_clients(id),
|
||||
access_token_hash TEXT NOT NULL,
|
||||
refresh_token_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE oidc_id_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
client_id UUID NOT NULL REFERENCES oauth_clients(id),
|
||||
id_token_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE newsletter_lists (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE newsletter_subscriptions (
|
||||
id UUID PRIMARY KEY,
|
||||
list_id UUID NOT NULL REFERENCES newsletter_lists(id),
|
||||
email TEXT NOT NULL,
|
||||
user_id UUID REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (list_id, email)
|
||||
);
|
||||
|
||||
CREATE TABLE email_verifications (
|
||||
id UUID PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE unsubscribe_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id),
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY,
|
||||
actor_type TEXT NOT NULL,
|
||||
actor_id UUID,
|
||||
action TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE user_roles (
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
role_id UUID NOT NULL REFERENCES roles(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_newsletter_subscriptions_email
|
||||
ON newsletter_subscriptions(email);
|
||||
|
||||
CREATE INDEX idx_newsletter_subscriptions_list_id
|
||||
ON newsletter_subscriptions(list_id);
|
||||
|
||||
CREATE INDEX idx_oauth_tokens_user_id
|
||||
ON oauth_tokens(user_id);
|
||||
|
||||
CREATE INDEX idx_oauth_tokens_client_id
|
||||
ON oauth_tokens(client_id);
|
||||
|
||||
CREATE INDEX idx_oauth_codes_client_id
|
||||
ON oauth_codes(client_id);
|
||||
|
||||
CREATE INDEX idx_email_verifications_email
|
||||
ON email_verifications(email);
|
||||
|
||||
CREATE INDEX idx_user_roles_role_id
|
||||
ON user_roles(role_id);
|
||||
28
docs/SEED.sql
Normal file
28
docs/SEED.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- Seed data for roles and initial admin
|
||||
|
||||
-- Roles
|
||||
INSERT INTO roles (id, name)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'admin'),
|
||||
(gen_random_uuid(), 'support')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Initial admin user (placeholder password hash)
|
||||
-- Replace password_hash with your identity provider hash.
|
||||
INSERT INTO users (id, email, password_hash, email_verified_at, status)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
'admin@example.com',
|
||||
'<PASSWORD_HASH>',
|
||||
now(),
|
||||
'active'
|
||||
)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- Assign admin role to the admin user
|
||||
INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT u.id, r.id
|
||||
FROM users u
|
||||
JOIN roles r ON r.name = 'admin'
|
||||
WHERE u.email = 'admin@example.com'
|
||||
ON CONFLICT DO NOTHING;
|
||||
15
docs/TECH_STACK.md
Normal file
15
docs/TECH_STACK.md
Normal file
@ -0,0 +1,15 @@
|
||||
# 技術堆疊(已選定)
|
||||
|
||||
## 核心
|
||||
- 語言/框架:C# .NET Core + MVC
|
||||
- OAuth2/OIDC:OpenIddict
|
||||
- DB:PostgreSQL
|
||||
- 事件同步:Event/Queue
|
||||
|
||||
## 建議元件
|
||||
- ORM:EF Core + Npgsql
|
||||
- Auth:ASP.NET Core Identity
|
||||
- 背景任務:Hangfire 或 Quartz.NET
|
||||
- 事件匯流:MassTransit + RabbitMQ/Kafka(依部署環境擇一)
|
||||
- 測試:xUnit + Testcontainers
|
||||
- 可觀測性:OpenTelemetry + Serilog
|
||||
35
docs/UI.md
Normal file
35
docs/UI.md
Normal file
@ -0,0 +1,35 @@
|
||||
# UI 規劃(管理者 / 會員)
|
||||
|
||||
## 會員中心(統一 UI)
|
||||
### 會員端
|
||||
- 註冊 / 登入 / 忘記密碼
|
||||
- Email 驗證
|
||||
- 個人資料(Email 為主)
|
||||
- 訂閱管理(清單與偏好)
|
||||
- 退訂(單一清單)
|
||||
- 連結外站(可選:回到來源站點)
|
||||
|
||||
### 管理者端
|
||||
- 租戶管理(Tenant CRUD)
|
||||
- OAuth Client 管理(redirect_uris / scopes)
|
||||
- 電子報清單管理(Lists CRUD)
|
||||
- 訂閱查詢 / 匯出
|
||||
- 審計紀錄查詢
|
||||
- 系統設定(安全策略、token 時效)
|
||||
|
||||
## 各站自建 UI(API)
|
||||
### 會員端
|
||||
- 登入 / 註冊 / 忘記密碼
|
||||
- Email 驗證頁(可自建或導回會員中心)
|
||||
- 訂閱表單(未登入)
|
||||
- 訂閱偏好管理(登入後)
|
||||
- 退訂頁(從 email token 進來)
|
||||
|
||||
### 管理者端
|
||||
- 站內顯示會員資料(只讀)
|
||||
- 站內訂閱狀態顯示
|
||||
|
||||
## UI 核心原則
|
||||
- 會員中心 UI 為統一入口(少數情境)
|
||||
- 其餘皆走 API 與各站自建 UI
|
||||
- 會員中心 UI 不承擔行銷內容或寄送
|
||||
30
docs/USE_CASES.md
Normal file
30
docs/USE_CASES.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Use Cases
|
||||
|
||||
標記:
|
||||
- [API] 代表各站自建 UI,透過 API 完成
|
||||
- [UI] 代表使用會員中心統一 UI
|
||||
|
||||
## 會員端
|
||||
- UC-01 註冊會員 [API/UI]
|
||||
- UC-02 登入(取得 token) [API/UI]
|
||||
- UC-03 登出 [API/UI]
|
||||
- UC-04 忘記密碼 / 重設密碼 [API/UI]
|
||||
- UC-05 Email 驗證 [API/UI]
|
||||
- UC-06 訂閱電子報(未登入) [API]
|
||||
- UC-07 訂閱確認(double opt-in) [UI]
|
||||
- UC-08 取消訂閱(單一清單) [UI]
|
||||
- UC-09 訂閱偏好管理(登入後) [API/UI]
|
||||
- UC-10 會員資料查看(Email 為主) [API/UI]
|
||||
|
||||
## 管理者端
|
||||
- UC-11 租戶管理 [UI]
|
||||
- UC-12 OAuth Client 管理 [UI]
|
||||
- UC-13 電子報清單管理 [UI]
|
||||
- UC-14 訂閱查詢 / 匯出 [UI]
|
||||
- UC-15 審計紀錄查詢 [UI]
|
||||
- UC-16 安全策略設定(token 時效等) [UI]
|
||||
|
||||
## 系統 / 整合
|
||||
- UC-17 發送訂閱事件(event/queue) [API]
|
||||
- UC-18 發送退訂事件(event/queue) [API]
|
||||
- UC-19 訂閱與會員綁定 [API]
|
||||
603
docs/openapi.yaml
Normal file
603
docs/openapi.yaml
Normal file
@ -0,0 +1,603 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Member Center API
|
||||
version: 0.1.0
|
||||
description: OAuth2/OIDC + Newsletter Subscription API
|
||||
servers:
|
||||
- url: /api
|
||||
security:
|
||||
- BearerAuth: []
|
||||
|
||||
paths:
|
||||
/oauth/authorize:
|
||||
get:
|
||||
summary: OAuth2 Authorization Endpoint
|
||||
description: Authorization Code + PKCE flow
|
||||
parameters:
|
||||
- in: query
|
||||
name: client_id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: redirect_uri
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: response_type
|
||||
required: true
|
||||
schema: { type: string, enum: [code] }
|
||||
- in: query
|
||||
name: scope
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: code_challenge
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: code_challenge_method
|
||||
required: true
|
||||
schema: { type: string, enum: [S256] }
|
||||
- in: query
|
||||
name: state
|
||||
required: false
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect to client with code
|
||||
|
||||
/oauth/token:
|
||||
post:
|
||||
summary: OAuth2 Token Endpoint
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
type: object
|
||||
required: [grant_type, code, redirect_uri, client_id, code_verifier]
|
||||
properties:
|
||||
grant_type: { type: string, enum: [authorization_code, refresh_token] }
|
||||
code: { type: string }
|
||||
redirect_uri: { type: string }
|
||||
client_id: { type: string }
|
||||
code_verifier: { type: string }
|
||||
refresh_token: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Token response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenResponse'
|
||||
|
||||
/.well-known/openid-configuration:
|
||||
get:
|
||||
summary: OIDC Discovery
|
||||
responses:
|
||||
'200':
|
||||
description: OIDC discovery document
|
||||
|
||||
/.well-known/jwks.json:
|
||||
get:
|
||||
summary: JWKS
|
||||
responses:
|
||||
'200':
|
||||
description: JSON Web Key Set
|
||||
|
||||
/auth/register:
|
||||
post:
|
||||
summary: Register user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Registered
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserProfile'
|
||||
|
||||
/auth/login:
|
||||
post:
|
||||
summary: API login
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Token response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenResponse'
|
||||
|
||||
/auth/refresh:
|
||||
post:
|
||||
summary: Refresh token
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RefreshRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Token response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenResponse'
|
||||
|
||||
/auth/logout:
|
||||
post:
|
||||
summary: Logout (revoke refresh token)
|
||||
security:
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Logged out
|
||||
|
||||
/auth/password/forgot:
|
||||
post:
|
||||
summary: Request password reset
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ForgotPasswordRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Email sent
|
||||
|
||||
/auth/password/reset:
|
||||
post:
|
||||
summary: Reset password
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResetPasswordRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Password reset
|
||||
|
||||
/auth/email/verify:
|
||||
get:
|
||||
summary: Verify email
|
||||
parameters:
|
||||
- in: query
|
||||
name: token
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Email verified
|
||||
|
||||
/user/profile:
|
||||
get:
|
||||
summary: Get current user profile
|
||||
security:
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Profile
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserProfile'
|
||||
|
||||
/newsletter/subscribe:
|
||||
post:
|
||||
summary: Subscribe (unauthenticated allowed)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SubscribeRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Pending subscription
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
|
||||
/newsletter/confirm:
|
||||
get:
|
||||
summary: Confirm subscription (double opt-in)
|
||||
parameters:
|
||||
- in: query
|
||||
name: token
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Active subscription
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
|
||||
/newsletter/unsubscribe:
|
||||
post:
|
||||
summary: Unsubscribe single list
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token]
|
||||
properties:
|
||||
token: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Unsubscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
|
||||
/newsletter/preferences:
|
||||
get:
|
||||
summary: Get preferences
|
||||
parameters:
|
||||
- in: query
|
||||
name: subscription_id
|
||||
required: false
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: tenant_id
|
||||
required: false
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: email
|
||||
required: false
|
||||
schema: { type: string, format: email }
|
||||
responses:
|
||||
'200':
|
||||
description: Preferences
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
|
||||
post:
|
||||
summary: Update preferences
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [subscription_id, preferences]
|
||||
properties:
|
||||
subscription_id: { type: string }
|
||||
preferences: { type: object }
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Subscription'
|
||||
|
||||
/admin/tenants:
|
||||
get:
|
||||
summary: List tenants
|
||||
security: [{ BearerAuth: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: List
|
||||
post:
|
||||
summary: Create tenant
|
||||
security: [{ BearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Tenant'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
|
||||
/admin/tenants/{id}:
|
||||
get:
|
||||
summary: Get tenant
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Tenant
|
||||
put:
|
||||
summary: Update tenant
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Tenant'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
delete:
|
||||
summary: Delete tenant
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
/admin/newsletter-lists:
|
||||
get:
|
||||
summary: List newsletter lists
|
||||
security: [{ BearerAuth: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: List
|
||||
post:
|
||||
summary: Create list
|
||||
security: [{ BearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NewsletterList'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
|
||||
/admin/newsletter-lists/{id}:
|
||||
get:
|
||||
summary: Get list
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: List
|
||||
put:
|
||||
summary: Update list
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NewsletterList'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
delete:
|
||||
summary: Delete list
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
/admin/oauth-clients:
|
||||
get:
|
||||
summary: List OAuth clients
|
||||
security: [{ BearerAuth: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: List
|
||||
post:
|
||||
summary: Create OAuth client
|
||||
security: [{ BearerAuth: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OAuthClient'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
|
||||
/admin/oauth-clients/{id}:
|
||||
get:
|
||||
summary: Get OAuth client
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OAuth client
|
||||
put:
|
||||
summary: Update OAuth client
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OAuthClient'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
delete:
|
||||
summary: Delete OAuth client
|
||||
security: [{ BearerAuth: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuth2:
|
||||
type: oauth2
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: /api/oauth/authorize
|
||||
tokenUrl: /api/oauth/token
|
||||
scopes:
|
||||
openid: OpenID Connect
|
||||
email: Email
|
||||
profile: Basic profile
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
schemas:
|
||||
TokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
access_token: { type: string }
|
||||
refresh_token: { type: string }
|
||||
id_token: { type: string }
|
||||
token_type: { type: string, example: Bearer }
|
||||
expires_in: { type: integer }
|
||||
|
||||
RegisterRequest:
|
||||
type: object
|
||||
required: [email, password]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [email, password, client_id]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
password: { type: string }
|
||||
client_id: { type: string }
|
||||
scope: { type: string }
|
||||
|
||||
RefreshRequest:
|
||||
type: object
|
||||
required: [refresh_token, client_id]
|
||||
properties:
|
||||
refresh_token: { type: string }
|
||||
client_id: { type: string }
|
||||
|
||||
ForgotPasswordRequest:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
|
||||
ResetPasswordRequest:
|
||||
type: object
|
||||
required: [token, new_password]
|
||||
properties:
|
||||
token: { type: string }
|
||||
new_password: { type: string }
|
||||
|
||||
UserProfile:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
email_verified: { type: boolean }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
SubscribeRequest:
|
||||
type: object
|
||||
required: [tenant_id, list_id, email]
|
||||
properties:
|
||||
tenant_id: { type: string }
|
||||
list_id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
preferences: { type: object }
|
||||
source: { type: string }
|
||||
|
||||
Subscription:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
tenant_id: { type: string }
|
||||
list_id: { type: string }
|
||||
email: { type: string, format: email }
|
||||
status: { type: string, enum: [pending, active, unsubscribed] }
|
||||
preferences: { type: object }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
Tenant:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
domains: { type: array, items: { type: string } }
|
||||
status: { type: string }
|
||||
|
||||
NewsletterList:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
tenant_id: { type: string }
|
||||
name: { type: string }
|
||||
status: { type: string }
|
||||
|
||||
OAuthClient:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
tenant_id: { type: string }
|
||||
name: { type: string }
|
||||
redirect_uris: { type: array, items: { type: string } }
|
||||
client_type: { type: string, enum: [public, confidential] }
|
||||
Loading…
x
Reference in New Issue
Block a user