feat: Update subscription disable API to include tenant-boundary validation and enhance request structure
This commit is contained in:
parent
ae6edae39c
commit
b355ed9e14
@ -104,9 +104,12 @@
|
||||
4) Send Engine 驗證 tenant scope,更新本地名單快照
|
||||
|
||||
### 6.7 Send Engine 退信/黑名單回寫(選用)
|
||||
1) Send Engine 判定黑名單(例如 hard bounce / complaint)
|
||||
2) 呼叫 Member Center API 將 email 加入 `email_blacklist`
|
||||
3) Member Center 對該 email 的訂閱事件全部忽略且不再推送
|
||||
1) Send Engine 依事件類型決定回寫時機與原因碼:
|
||||
2) `hard_bounce` / `soft_bounce_threshold` / `suppression`:設黑名單後回寫
|
||||
3) `complaint`:先取消訂閱,再回寫黑名單
|
||||
4) 呼叫 Member Center API 將 email 加入 `email_blacklist`
|
||||
5) Member Center 對該 email 的訂閱事件全部忽略且不再推送
|
||||
6) 回寫請求需帶 `tenant_id + subscriber_id + list_id`,Member Center 端做租戶邊界驗證
|
||||
|
||||
### 6.5 註冊後銜接
|
||||
1) 使用者完成註冊
|
||||
@ -128,14 +131,14 @@
|
||||
- POST `/newsletter/preferences`
|
||||
- POST `/webhooks/subscriptions`(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(規劃中)
|
||||
### API
|
||||
- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單
|
||||
- `POST /webhooks/subscriptions`: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
|
||||
- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
||||
|
||||
@ -45,8 +45,10 @@
|
||||
- [API] Send Engine 更新名單快照
|
||||
|
||||
## F-11 黑名單回寫(Send Engine → Member Center)
|
||||
- [API] Send Engine 判定黑名單(例如 hard bounce / complaint)
|
||||
- [API] 呼叫 `POST /api/subscriptions/disable`(scope: `newsletter:events.write`)
|
||||
- [API] Send Engine 依事件規則處理:
|
||||
- [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`,停用寄送並停止事件推送
|
||||
|
||||
## F-07 會員資料查看
|
||||
|
||||
@ -55,7 +55,9 @@
|
||||
- 驗證規則:
|
||||
- timestamp 在允許時間窗(例如 ±5 分鐘)
|
||||
- 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 必須匹配
|
||||
|
||||
## OAuth Client 用途分離(強制)
|
||||
@ -70,12 +72,12 @@
|
||||
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||
- secret 分開輪替,禁止共用
|
||||
|
||||
## 待新增 API / Auth(規劃中)
|
||||
## 整合 API / Auth(狀態)
|
||||
### API
|
||||
- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用)
|
||||
- `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送(Send Engine 端點,scope: `newsletter:events.write`)
|
||||
- `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步(Send Engine 端點,scope: `newsletter:events.write`)
|
||||
- `POST /api/subscriptions/disable`:Send Engine → Member Center 黑名單回寫(全租戶 email,scope: `newsletter:events.write`)
|
||||
- `GET /newsletter/subscriptions?list_id=...`:已實作(供發送引擎同步)
|
||||
- `POST /webhooks/subscriptions`:已實作(Member Center 發送;Send Engine 接收)
|
||||
- `POST /webhooks/lists/full-sync`:規格已定義(Member Center 發送;Send Engine 接收)
|
||||
- `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單)
|
||||
|
||||
### Auth / Scope
|
||||
- OAuth Client 需綁定 `tenant_id`
|
||||
@ -84,3 +86,21 @@
|
||||
- `newsletter:events.read`
|
||||
- `newsletter:events.write`
|
||||
- 發送引擎僅能用上述 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 黑名單。
|
||||
|
||||
@ -436,11 +436,11 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/api/subscriptions/disable:
|
||||
/subscriptions/disable:
|
||||
post:
|
||||
summary: Disable email (global blacklist)
|
||||
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:
|
||||
required: true
|
||||
content:
|
||||
@ -466,7 +466,7 @@ paths:
|
||||
/webhooks/subscriptions:
|
||||
post:
|
||||
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: []
|
||||
parameters:
|
||||
- in: header
|
||||
@ -498,7 +498,7 @@ paths:
|
||||
/webhooks/lists/full-sync:
|
||||
post:
|
||||
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: []
|
||||
parameters:
|
||||
- in: header
|
||||
@ -829,10 +829,14 @@ components:
|
||||
|
||||
DisableSubscriptionRequest:
|
||||
type: object
|
||||
required: [email, reason, disabled_by, occurred_at]
|
||||
required: [tenant_id, subscriber_id, list_id, reason, disabled_by, occurred_at]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
reason: { type: string }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
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 }
|
||||
occurred_at: { type: string, format: date-time }
|
||||
|
||||
|
||||
@ -21,7 +21,9 @@ public sealed record UpdatePreferencesRequest(
|
||||
[property: JsonPropertyName("preferences")] Dictionary<string, object> Preferences);
|
||||
|
||||
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("disabled_by")] string DisabledBy,
|
||||
[property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt);
|
||||
|
||||
@ -1,20 +1,34 @@
|
||||
using MemberCenter.Api.Contracts;
|
||||
using MemberCenter.Application.Abstractions;
|
||||
using MemberCenter.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace MemberCenter.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/subscriptions")]
|
||||
[Route("subscriptions")]
|
||||
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;
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
@ -26,20 +40,48 @@ public class SubscriptionsController : ControllerBase
|
||||
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.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(
|
||||
request.Email,
|
||||
target.Email,
|
||||
request.Reason,
|
||||
request.DisabledBy,
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user