feat: Update subscription disable API to include tenant-boundary validation and enhance request structure

This commit is contained in:
warrenchen 2026-02-18 12:57:21 +09:00
parent ae6edae39c
commit b355ed9e14
6 changed files with 101 additions and 28 deletions

View File

@ -104,9 +104,12 @@
4) Send Engine 驗證 tenant scope更新本地名單快照 4) Send Engine 驗證 tenant scope更新本地名單快照
### 6.7 Send Engine 退信/黑名單回寫(選用) ### 6.7 Send Engine 退信/黑名單回寫(選用)
1) Send Engine 判定黑名單(例如 hard bounce / complaint 1) Send Engine 依事件類型決定回寫時機與原因碼:
2) 呼叫 Member Center API 將 email 加入 `email_blacklist` 2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
3) Member Center 對該 email 的訂閱事件全部忽略且不再推送 3) `complaint`:先取消訂閱,再回寫黑名單
4) 呼叫 Member Center API 將 email 加入 `email_blacklist`
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`Member Center 端做租戶邊界驗證
### 6.5 註冊後銜接 ### 6.5 註冊後銜接
1) 使用者完成註冊 1) 使用者完成註冊
@ -128,14 +131,14 @@
- POST `/newsletter/preferences` - POST `/newsletter/preferences`
- POST `/webhooks/subscriptions`Send Engine 端點Member Center 呼叫) - POST `/webhooks/subscriptions`Send Engine 端點Member Center 呼叫)
- POST `/webhooks/lists/full-sync`Send Engine 端點Member Center 呼叫) - POST `/webhooks/lists/full-sync`Send Engine 端點Member Center 呼叫)
- POST `/api/subscriptions/disable`Member Center 端點Send Engine 呼叫) - POST `/subscriptions/disable`Member Center 端點Send Engine 呼叫)
## 7.1 待新增 API / Auth規劃中 ## 7.1 待新增 API / Auth規劃中
### API ### API
- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單 - `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單
- `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送 - `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送
- `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步 - `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步
- `POST /api/subscriptions/disable`Send Engine → Member Center 黑名單回寫 - `POST /subscriptions/disable`Send Engine → Member Center 黑名單回寫
### Auth / Scope ### Auth / Scope
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 - OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界

View File

@ -45,8 +45,10 @@
- [API] Send Engine 更新名單快照 - [API] Send Engine 更新名單快照
## F-11 黑名單回寫Send Engine → Member Center ## F-11 黑名單回寫Send Engine → Member Center
- [API] Send Engine 判定黑名單(例如 hard bounce / complaint - [API] Send Engine 依事件規則處理:
- [API] 呼叫 `POST /api/subscriptions/disable`scope: `newsletter:events.write` - [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
- [API] `complaint`:先在 Send Engine 取消訂閱,再回寫黑名單
- [API] 呼叫 `POST /subscriptions/disable`scope: `newsletter:events.write`body 需含 `tenant_id + subscriber_id + list_id + reason + disabled_by + occurred_at`
- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送 - [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送
## F-07 會員資料查看 ## F-07 會員資料查看

View File

@ -55,7 +55,9 @@
- 驗證規則: - 驗證規則:
- timestamp 在允許時間窗(例如 ±5 分鐘) - timestamp 在允許時間窗(例如 ±5 分鐘)
- nonce 不可重複(防重放) - nonce 不可重複(防重放)
- `X-Client-Id` 必須存在且 active且 tenant 綁定一致 - `X-Client-Id` 必須存在且 active`auth_clients.tenant_id` 與 payload `tenant_id` 一致
- 不使用 `X-Client-Id` fallback缺少 tenant 對應 client 時應略過發送
- 預設拒絕 `auth_clients.tenant_id = NULL` 的通用 client除非 Send Engine 明確開啟)
- signature 必須匹配 - signature 必須匹配
## OAuth Client 用途分離(強制) ## OAuth Client 用途分離(強制)
@ -70,12 +72,12 @@
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound` - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- secret 分開輪替,禁止共用 - secret 分開輪替,禁止共用
## 待新增 API / Auth規劃中 ## 整合 API / Auth狀態
### API ### API
- `GET /newsletter/subscriptions?list_id=...`回傳清單內所有訂閱(供發送引擎同步用 - `GET /newsletter/subscriptions?list_id=...`已實作(供發送引擎同步
- `POST /webhooks/subscriptions`Member Center → Send Engine 事件推送Send Engine 端點scope: `newsletter:events.write` - `POST /webhooks/subscriptions`已實作Member Center 發送Send Engine 接收
- `POST /webhooks/lists/full-sync`Member Center → Send Engine 全量同步Send Engine 端點scope: `newsletter:events.write` - `POST /webhooks/lists/full-sync`規格已定義Member Center 發送Send Engine 接收
- `POST /api/subscriptions/disable`Send Engine → Member Center 黑名單回寫(全租戶 emailscope: `newsletter:events.write` - `POST /subscriptions/disable`已實作Send Engine 回寫黑名單
### Auth / Scope ### Auth / Scope
- OAuth Client 需綁定 `tenant_id` - OAuth Client 需綁定 `tenant_id`
@ -84,3 +86,21 @@
- `newsletter:events.read` - `newsletter:events.read`
- `newsletter:events.write` - `newsletter:events.write`
- 發送引擎僅能用上述 scope禁止 admin 權限 - 發送引擎僅能用上述 scope禁止 admin 權限
- `POST /subscriptions/disable` 需 Bearer token 且包含 `newsletter:events.write`
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
### 回寫原因碼Send Engine -> Member Center
- `hard_bounce`
- `soft_bounce_threshold`
- `complaint`
- `suppression`
### `/subscriptions/disable` 請求欄位Send Engine -> Member Center
- `tenant_id`UUID
- `subscriber_id`UUID
- `list_id`UUID
- `reason``hard_bounce | soft_bounce_threshold | complaint | suppression`
- `disabled_by`(建議固定 `send_engine`
- `occurred_at`RFC3339
Member Center 會用 `subscriber_id + list_id` 查詢訂閱,再驗證 `tenant_id` 邊界;驗證通過後才寫入全域 email 黑名單。

View File

@ -436,11 +436,11 @@ paths:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/api/subscriptions/disable: /subscriptions/disable:
post: post:
summary: Disable email (global blacklist) summary: Disable email (global blacklist)
security: [{ BearerAuth: [] }] security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:events.write`. description: Requires scope `newsletter:events.write`. Request must include `tenant_id`, `subscriber_id`, and `list_id` for tenant-boundary validation. Expected reason codes: `hard_bounce`, `soft_bounce_threshold`, `complaint`, `suppression`.
requestBody: requestBody:
required: true required: true
content: content:
@ -466,7 +466,7 @@ paths:
/webhooks/subscriptions: /webhooks/subscriptions:
post: post:
summary: (NOTE) Member Center -> Send Engine subscription events webhook summary: (NOTE) Member Center -> Send Engine subscription events webhook
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID). description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id.
security: [] security: []
parameters: parameters:
- in: header - in: header
@ -498,7 +498,7 @@ paths:
/webhooks/lists/full-sync: /webhooks/lists/full-sync:
post: post:
summary: (NOTE) Member Center -> Send Engine full list sync webhook summary: (NOTE) Member Center -> Send Engine full list sync webhook
description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID). description: This endpoint is implemented by Send Engine; listed here as an integration note. Required headers are X-Signature, X-Timestamp, X-Nonce, X-Client-Id. X-Client-Id must be Send Engine `auth_clients.id` (UUID), tenant-bound, no fallback client id.
security: [] security: []
parameters: parameters:
- in: header - in: header
@ -829,10 +829,14 @@ components:
DisableSubscriptionRequest: DisableSubscriptionRequest:
type: object type: object
required: [email, reason, disabled_by, occurred_at] required: [tenant_id, subscriber_id, list_id, reason, disabled_by, occurred_at]
properties: properties:
email: { type: string, format: email } tenant_id: { type: string, format: uuid }
reason: { type: string } subscriber_id: { type: string, format: uuid }
list_id: { type: string, format: uuid }
reason:
type: string
enum: [hard_bounce, soft_bounce_threshold, complaint, suppression]
disabled_by: { type: string } disabled_by: { type: string }
occurred_at: { type: string, format: date-time } occurred_at: { type: string, format: date-time }

View File

@ -21,7 +21,9 @@ public sealed record UpdatePreferencesRequest(
[property: JsonPropertyName("preferences")] Dictionary<string, object> Preferences); [property: JsonPropertyName("preferences")] Dictionary<string, object> Preferences);
public sealed record DisableSubscriptionRequest( public sealed record DisableSubscriptionRequest(
[property: JsonPropertyName("email")] string Email, [property: JsonPropertyName("tenant_id")] Guid TenantId,
[property: JsonPropertyName("subscriber_id")] Guid SubscriberId,
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("reason")] string Reason, [property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("disabled_by")] string DisabledBy, [property: JsonPropertyName("disabled_by")] string DisabledBy,
[property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt); [property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt);

View File

@ -1,20 +1,34 @@
using MemberCenter.Api.Contracts; using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
[ApiController] [ApiController]
[Route("api/subscriptions")] [Route("subscriptions")]
public class SubscriptionsController : ControllerBase public class SubscriptionsController : ControllerBase
{ {
private readonly IEmailBlacklistService _emailBlacklistService; private static readonly HashSet<string> AllowedReasons = new(StringComparer.OrdinalIgnoreCase)
{
"hard_bounce",
"soft_bounce_threshold",
"complaint",
"suppression"
};
public SubscriptionsController(IEmailBlacklistService emailBlacklistService) private readonly IEmailBlacklistService _emailBlacklistService;
private readonly MemberCenterDbContext _dbContext;
public SubscriptionsController(
IEmailBlacklistService emailBlacklistService,
MemberCenterDbContext dbContext)
{ {
_emailBlacklistService = emailBlacklistService; _emailBlacklistService = emailBlacklistService;
_dbContext = dbContext;
} }
[Authorize] [Authorize]
@ -26,20 +40,48 @@ public class SubscriptionsController : ControllerBase
return Forbid(); return Forbid();
} }
if (string.IsNullOrWhiteSpace(request.Email) || if (request.TenantId == Guid.Empty ||
request.SubscriberId == Guid.Empty ||
request.ListId == Guid.Empty ||
string.IsNullOrWhiteSpace(request.Reason) || string.IsNullOrWhiteSpace(request.Reason) ||
string.IsNullOrWhiteSpace(request.DisabledBy)) string.IsNullOrWhiteSpace(request.DisabledBy))
{ {
return BadRequest("email, reason, disabled_by are required."); return BadRequest("tenant_id, subscriber_id, list_id, reason, disabled_by are required.");
}
if (!AllowedReasons.Contains(request.Reason))
{
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
}
var target = await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
where subscription.Id == request.SubscriberId && subscription.ListId == request.ListId
select new
{
subscription.Email,
list.TenantId
})
.FirstOrDefaultAsync();
if (target is null)
{
return NotFound("Subscription not found.");
}
if (target.TenantId != request.TenantId)
{
return BadRequest("tenant_id does not match subscription/list tenant boundary.");
} }
await _emailBlacklistService.AddOrUpdateAsync( await _emailBlacklistService.AddOrUpdateAsync(
request.Email, target.Email,
request.Reason, request.Reason,
request.DisabledBy, request.DisabledBy,
request.OccurredAt); request.OccurredAt);
return Ok(new { email = request.Email, status = "blacklisted" }); return Ok(new { email = target.Email, status = "blacklisted" });
} }
private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope) private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope)