feat: Update subscription disable logic to handle blacklisting and complaints distinctly
This commit is contained in:
parent
4fbf2e5497
commit
febf870807
@ -111,12 +111,10 @@
|
|||||||
|
|
||||||
### 6.7 Send Engine 退信/黑名單回寫(選用)
|
### 6.7 Send Engine 退信/黑名單回寫(選用)
|
||||||
1) Send Engine 依事件類型決定回寫時機與原因碼:
|
1) Send Engine 依事件類型決定回寫時機與原因碼:
|
||||||
2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
|
2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:Member Center 取消該 email 所有訂閱(跨租戶)並加入黑名單
|
||||||
3) `complaint`:先取消訂閱,再回寫黑名單
|
3) `complaint`:Member Center 僅取消該筆訂閱,不加入黑名單
|
||||||
4) 呼叫 Member Center API 將 email 加入 `email_blacklist`
|
4) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證
|
||||||
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送
|
5) 回寫授權可用:
|
||||||
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證
|
|
||||||
7) 回寫授權可用:
|
|
||||||
- tenant client scope:`newsletter:events.write`
|
- tenant client scope:`newsletter:events.write`
|
||||||
- platform client scope:`newsletter:events.write.global`(SES 聚合事件)
|
- platform client scope:`newsletter:events.write.global`(SES 聚合事件)
|
||||||
|
|
||||||
@ -167,6 +165,13 @@
|
|||||||
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
||||||
- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫
|
- 驗簽通過後將 `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. 安全與合規
|
## 8. 安全與合規
|
||||||
- 密碼強度與防暴力破解(rate limit + lockout)
|
- 密碼強度與防暴力破解(rate limit + lockout)
|
||||||
- Token rotation + refresh token revoke
|
- Token rotation + refresh token revoke
|
||||||
|
|||||||
@ -58,13 +58,13 @@
|
|||||||
|
|
||||||
## F-11 黑名單回寫(Send Engine → Member Center)
|
## F-11 黑名單回寫(Send Engine → Member Center)
|
||||||
- [API] Send Engine 依事件規則處理:
|
- [API] Send Engine 依事件規則處理:
|
||||||
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
|
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:回寫後由 Member Center 取消該 email 的所有訂閱(跨租戶)並加入黑名單
|
||||||
- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單
|
- [API] `complaint`:回寫後由 Member Center 僅取消該筆訂閱,不加入黑名單
|
||||||
- [API] 呼叫 `POST /subscriptions/disable`:
|
- [API] 呼叫 `POST /subscriptions/disable`:
|
||||||
- [API] tenant client 用 `newsletter:events.write`
|
- [API] tenant client 用 `newsletter:events.write`
|
||||||
- [API] 平台 client(SES 聚合事件)用 `newsletter:events.write.global`
|
- [API] 平台 client(SES 聚合事件)用 `newsletter:events.write.global`
|
||||||
- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
|
- [API] body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
|
||||||
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
|
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression` 才會寫入 `email_blacklist`;`complaint` 不寫黑名單
|
||||||
|
|
||||||
## F-12 Webhook Client Mapping 回填(Send Engine → Member Center)
|
## F-12 Webhook Client Mapping 回填(Send Engine → Member Center)
|
||||||
- [API] Send Engine 建立/更新 tenant 對應的 webhook client(`auth_clients.id`)
|
- [API] Send Engine 建立/更新 tenant 對應的 webhook client(`auth_clients.id`)
|
||||||
@ -81,3 +81,5 @@
|
|||||||
## F-09 訂閱與會員綁定
|
## F-09 訂閱與會員綁定
|
||||||
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
|
- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定
|
||||||
- [API] 發送事件 `subscription.linked_to_user`
|
- [API] 發送事件 `subscription.linked_to_user`
|
||||||
|
|
||||||
|
註記:此流程目前尚未在程式中落地(屬待辦)。
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
# OpenAPI 草案(完整)
|
# OpenAPI 草案(完整)
|
||||||
|
|
||||||
已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
|
已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
|
||||||
|
其中 `/webhooks/*` 為 Member Center 對外發送時遵循的整合契約(實際由 Send Engine 提供端點)。
|
||||||
|
|
||||||
## 版本
|
## 版本
|
||||||
- OpenAPI: 3.1.0
|
- OpenAPI: 3.1.0
|
||||||
@ -123,6 +124,14 @@
|
|||||||
- `complaint`
|
- `complaint`
|
||||||
- `suppression`
|
- `suppression`
|
||||||
|
|
||||||
|
處理規則:
|
||||||
|
- `hard_bounce` / `soft_bounce_threshold` / `suppression`:
|
||||||
|
- 取消該 email 的所有訂閱(跨租戶)
|
||||||
|
- 加入全域黑名單
|
||||||
|
- `complaint`:
|
||||||
|
- 僅取消該筆訂閱(依 `subscriber_id + list_id`)
|
||||||
|
- 不加入黑名單
|
||||||
|
|
||||||
### `/subscriptions/disable` 請求欄位(Send Engine -> Member Center)
|
### `/subscriptions/disable` 請求欄位(Send Engine -> Member Center)
|
||||||
- `tenant_id`(UUID)
|
- `tenant_id`(UUID)
|
||||||
- `subscriber_id`(UUID)
|
- `subscriber_id`(UUID)
|
||||||
|
|||||||
@ -28,3 +28,7 @@
|
|||||||
- UC-17 發送訂閱事件(event/queue) [API]
|
- UC-17 發送訂閱事件(event/queue) [API]
|
||||||
- UC-18 發送退訂事件(event/queue) [API]
|
- UC-18 發送退訂事件(event/queue) [API]
|
||||||
- UC-19 訂閱與會員綁定 [API]
|
- UC-19 訂閱與會員綁定 [API]
|
||||||
|
|
||||||
|
## 實作狀態(2026-02)
|
||||||
|
- 已完成:UC-17、UC-18(以 webhook 事件發送)
|
||||||
|
- 未完成:UC-19(註冊後自動綁定 `user_id` 與 `subscription.linked_to_user` 事件)
|
||||||
|
|||||||
@ -510,9 +510,14 @@ paths:
|
|||||||
|
|
||||||
/subscriptions/disable:
|
/subscriptions/disable:
|
||||||
post:
|
post:
|
||||||
summary: Disable email (global blacklist)
|
summary: Disable subscription / blacklist by reason
|
||||||
security: [{ BearerAuth: [] }]
|
security: [{ BearerAuth: [] }]
|
||||||
description: Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client). Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
|
description: |
|
||||||
|
Requires scope `newsletter:events.write` (tenant client) or `newsletter:events.write.global` (platform client).
|
||||||
|
Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation.
|
||||||
|
Reason handling:
|
||||||
|
- `hard_bounce`, `soft_bounce_threshold`, `suppression`: unsubscribe all subscriptions for the email across tenants + add to global blacklist.
|
||||||
|
- `complaint`: unsubscribe only the target subscription, no blacklist.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using MemberCenter.Api.Contracts;
|
using MemberCenter.Api.Contracts;
|
||||||
using MemberCenter.Application.Abstractions;
|
using MemberCenter.Application.Abstractions;
|
||||||
|
using MemberCenter.Domain.Constants;
|
||||||
using MemberCenter.Infrastructure.Persistence;
|
using MemberCenter.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -21,6 +22,12 @@ public class SubscriptionsController : ControllerBase
|
|||||||
"complaint",
|
"complaint",
|
||||||
"suppression"
|
"suppression"
|
||||||
};
|
};
|
||||||
|
private static readonly HashSet<string> BlacklistReasons = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"hard_bounce",
|
||||||
|
"soft_bounce_threshold",
|
||||||
|
"suppression"
|
||||||
|
};
|
||||||
|
|
||||||
// TEST-ONLY SWITCH: keep this key stable so it is easy to find/remove after SES integration test.
|
// TEST-ONLY SWITCH: keep this key stable so it is easy to find/remove after SES integration test.
|
||||||
private const string DisableSubscriptionDryRunNoDbKey = "Testing:DisableSubscriptionDryRunNoDb";
|
private const string DisableSubscriptionDryRunNoDbKey = "Testing:DisableSubscriptionDryRunNoDb";
|
||||||
@ -121,13 +128,51 @@ public class SubscriptionsController : ControllerBase
|
|||||||
return BadRequest("tenant_id does not match subscription/list tenant boundary.");
|
return BadRequest("tenant_id does not match subscription/list tenant boundary.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _emailBlacklistService.AddOrUpdateAsync(
|
var normalizedEmail = target.Email.Trim().ToLowerInvariant();
|
||||||
target.Email,
|
var shouldBlacklist = BlacklistReasons.Contains(request.Reason);
|
||||||
request.Reason,
|
var cancelledCount = 0;
|
||||||
request.DisabledBy,
|
|
||||||
request.OccurredAt);
|
|
||||||
|
|
||||||
return Ok(new { email = target.Email, status = "blacklisted" });
|
if (shouldBlacklist)
|
||||||
|
{
|
||||||
|
// hard/soft/suppression: cancel across all tenants for the same email.
|
||||||
|
var subscriptions = await _dbContext.NewsletterSubscriptions
|
||||||
|
.Where(s => s.Email.ToLower() == normalizedEmail && s.Status != SubscriptionStatus.Unsubscribed)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var subscription in subscriptions)
|
||||||
|
{
|
||||||
|
subscription.Status = SubscriptionStatus.Unsubscribed;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelledCount = subscriptions.Count;
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
await _emailBlacklistService.AddOrUpdateAsync(
|
||||||
|
target.Email,
|
||||||
|
request.Reason,
|
||||||
|
request.DisabledBy,
|
||||||
|
request.OccurredAt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// complaint: cancel only the target subscription; do not blacklist.
|
||||||
|
var subscription = await _dbContext.NewsletterSubscriptions
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == request.SubscriberId && s.ListId == request.ListId);
|
||||||
|
if (subscription is not null && subscription.Status != SubscriptionStatus.Unsubscribed)
|
||||||
|
{
|
||||||
|
subscription.Status = SubscriptionStatus.Unsubscribed;
|
||||||
|
cancelledCount = 1;
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
email = target.Email,
|
||||||
|
status = shouldBlacklist ? "unsubscribed_blacklisted" : "unsubscribed",
|
||||||
|
blacklisted = shouldBlacklist,
|
||||||
|
cancelled_count = cancelledCount
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)
|
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user