---
doc_id: aile-service-platform-integration-spec
title: aile-service-platform-integration 服務設計規格（開發參考）
description: Aile Open Integration Layer 的服務邊界、領域模型、安裝握手、網關、事件、簽名與管理面設計規格，供開發團隊參考。
slug: /developers/aile-service-platform-integration-spec
product: Aile
category: development-reference
audience:
  - developer
  - architect
visibility: public
status: published
version: 1.0.0
owner: aile-platform
updated_at: 2026-06-12
tags:
  - Aile
  - Platform Integration
  - OpenAPI
  - 開發參考
rendered_html: /rendered/developers/aile-service-platform-integration-spec/
download: true
sidebar_position: 4
---

# aile-service-platform-integration 服務設計規格 v1.0

## 文件說明

本文件是 **aile-service-platform-integration** 服務的最終設計規格,作為開發團隊的實施指導文件。文件自包含,覆蓋服務邊界、領域模型、安裝握手協議、閘道器層、事件三段鏈路、簽名規則、管理面介面與資料遷移等全部交付範圍。

> [!NOTE]
> 本文件定位為開發參考用的臨時設計稿，協助團隊在正式規格文件發布前對齊服務設計與實作邊界；待正式規格建立後，可由正式文件取代並移除此參考稿。

閱讀物件:後端開發、測試、運維。

閱讀前提:已瞭解 Aile 多租戶生態(Aile、AIPower 等)與現有 aile-service-job 中 TenantAppModel / WebhookEventServiceImpl 的歷史實現。

---

## 一、設計目標

aile-service-platform-integration 是 Aile 生態中負責「平臺級整合底座」的獨立微服務,定位為 **Aile Open Integration Layer** 的工程實體——為所有外部生態應用(AIPower 及未來擴充套件)提供統一的安裝、鑑權、路由與事件出口,自身不持有任何業務領域邏輯。

核心目標:

- 提供 `IntegrationApp / TenantIntegration / TenantMapping` 三張核心模型,實現「平臺級應用定義」與「租戶級安裝例項」的分離
- 承擔 `/openapi/v1/*` 閘道器層:統一路由、簽名驗籤、鑑權切面,內部代理回各領域子服務,**不持有任何業務領域邏輯**
- 定義統一的 `EventEnvelope` 結構,封裝業務事件並推入 Pub/Sub,供獨立的 webhook 服務消費
- 實現 Aile 主導的安裝握手協議(`/install` / `/update` / `/uninstall` / `/rotate-secret`),完成租戶安裝例項的全生命週期管理
- 統一 HMAC-SHA256 簽名規則,消除當前 SignatureUtils 中三套不一致邏輯
- 替代當前 aile-service-job 中零散的 `TenantAppModel` / `WebhookEventServiceImpl`

---

## 二、服務邊界與責任宣告

本服務採用「**Open Integration Layer**」邊界,本服務**自有**與**僅代理**兩類能力的劃分如下:

| 能力 | 本服務定位 | 說明 |
| --- | --- | --- |
| IntegrationApp / TenantIntegration / TenantMapping | 自有領域 | 本服務獨佔的核心模型與狀態機 |
| 安裝握手協議(/install /update /uninstall /rotate-secret) | 自有介面 | 由本服務主導呼叫 AIPower 安裝入口 |
| 統一簽名 SDK / 驗籤 Filter | 自有能力 | 本服務暴露給所有 Aile 子服務複用 |
| /openapi/v1/* 閘道器 | 自有入口 + 代理轉發 | 路由 + 鑑權 + 簽名驗籤,領域邏輯回原服務 |
| 業務事件封裝 + Pub/Sub 投遞 + EventLog | 自有職責 | 本服務取代舊 WebhookEventServiceImpl 的「事件封裝+釋出」環節 |
| Webhook 出站投遞 / 重試 / DLQ | 不在本服務 | 由獨立 webhook 服務訂閱 Pub/Sub 完成 |
| 通知狀態事件(notice.*)封裝 + 釋出 | 自有職責 | message 服務透過 EventPublishClient 推入本服務,統一封裝為 EventEnvelope 後入 Pub/Sub,與業務事件共用同一 webhookUrl 投遞 |
| AIPower 反向能力呼叫(Aile→AIPower 執行時 API) | 本期不實現 | apiBaseUrl 欄位僅佔位,後期再設計 |
| AIFF / 業務物件 / 同步資源等所有領域邏輯 | 不在本服務 | 領域程式碼與儲存保留在 auth/job/tenant/account/room 等原服務,本服務僅作為閘道器代理 |
| SystemAppModel(內部服務間簽名) | 不動 | 保留在 aile-service-job,與 IntegrationApp 互不干涉 |

架構定點陣圖:

```mermaid
flowchart TB
    subgraph External["生態應用"]
        AIPower["AIPower"]
        Future["未來其他生態應用"]
    end

    subgraph PI["aile-service-platform-integration"]
        Gateway["/openapi/v1/* 閘道器層<br/>路由+鑑權+簽名驗籤"]
        Domain["整合關係領域層<br/>IntegrationApp / TenantIntegration / TenantMapping"]
        Install["安裝握手 + 控制面"]
        Publisher["事件封裝 + Pub/Sub 釋出器"]
        Log["EventLog 審計"]
    end

    subgraph PubSub["訊息匯流排"]
        Topic["Pub/Sub Topic"]
    end

    subgraph Webhook["獨立 Webhook 服務"]
        Consumer["消費 + 重試 + DLQ"]
    end

    subgraph Services["Aile 領域子服務"]
        Tenant["tenant"]
        Auth["auth (AIFF/sync/contact)"]
        Job["job"]
        Room["room"]
        Account["account"]
        Message["message"]
    end

    AIPower --> Gateway
    Future --> Gateway
    Gateway --> Tenant
    Gateway --> Auth
    Gateway --> Job
    Gateway --> Room
    Gateway --> Account

    Install --> AIPower

    Tenant --> Publisher
    Auth --> Publisher
    Room --> Publisher
    Message --> Publisher
    Publisher --> Topic
    Publisher --> Log
    Topic --> Consumer
    Consumer --> AIPower
```

---

## 三、本期關鍵設計決策

以下決策為本期實施的**最終口徑**,開發實施時直接遵循,不再做二次討論。

| 決策項 | 最終口徑 | 原因 / 說明 |
| --- | --- | --- |
| 外部租戶欄位命名 | 統一使用 externalTenantId / externalSpaceId | 面向未來多生態應用擴充套件提前抽象,避免與具體應用(如 AIPower)耦合 |
| Webhook URL 設計 | 統一為單一 `webhookUrl`,業務事件與 notice.* 通知事件全部投遞到該地址 | 事件分發本質上是第三方接收側的職責(EventEnvelope 已攜帶 eventType);合併端點簡化握手協議、路由約定、遷移指令碼與接收側接入成本 |
| 通知 notice.* 事件 | 與業務事件完全同鏈路同通道:message 服務 → EventPublishClient → 本服務 → Pub/Sub → 獨立 webhook 服務 → 統一 webhookUrl | 走 Pub/Sub 解耦,避免增加 message 服務的同步出站壓力;複用統一簽名 / 重試 / DLQ;下游接收側按 EventEnvelope.eventType 自行分發 |
| 反向能力呼叫(Aile → 外部應用執行時 API) | 本期不實現,apiBaseUrl 欄位僅佔位 | P0 範圍聚焦核心閉環 |
| Scope 鑑權 | 本期不實現,supportedScopes / grantedScopes 預設 ["*"] | 簡化首版,後續再做細粒度 |
| PENDING_USER_CONFIRM 狀態 | 列舉保留,反向回撥介面不實現 | 本期外部應用不需要人工審批流程 |
| Nonce 防重放 | 本期不實現(沿用現狀) | nonce 僅參與簽名計算,不快取查重,後續如有安全需求再加 |
| Secret 儲存 | 明文儲存 Mongo | 沿用現狀,後續如啟用 KMS 再遷移 |
| 金鑰輪換策略 | 硬切換,舊金鑰立即失效 | 實現簡單,本期可接受短暫中斷 |
| 舊介面相容 | 不提供灰度相容期,遷移後直接下線 /tenantapp/v1/* | 降低長期維護成本 |
| EventLog 用途 | 僅作審計,不作重試源 | 重試由下游獨立 webhook 服務負責 |

---

## 四、命名規範與術語

採用三層命名以消除舊 `appId` 二義性(全域性應用定義、租戶安裝例項、租戶實體三者明確分離)。下表為本服務的正式命名口徑,所有程式碼、介面、文件須統一使用。

| 正式命名 | 含義 | 使用位置 |
| --- | --- | --- |
| `integrationAppId` | 全域性生態應用定義 ID,例如 `aipower`。全平臺唯一,不隨租戶變化 | IntegrationApp.appId、EventEnvelope.integration.appId |
| `tenantIntegrationId` | 租戶安裝例項 ID,簽名鑑權與路由的主要依據 | 簽名 header、TenantIntegration.integrationId、EventEnvelope.integration.integrationId |
| `tenantIntegrationSecret` | 租戶安裝例項簽名金鑰 | 簽名演算法輸入,僅本服務與該安裝例項持有 |
| `aileTenantId` | Aile 側的租戶 ID | TenantIntegration.aileTenantId、TenantMapping.aileTenantId |
| `externalTenantId` / `externalSpaceId` | 外部生態應用側的租戶/空間 ID(抽象命名,適配 AIPower 及未來其他生態應用) | TenantMapping.externalTenantId、EventEnvelope.tenant.mappedExternalTenantId |
| `ownerType` / `ownerId` | 個人租戶隔離維度 | TenantMapping.ownerType / ownerId |

簽名 Header 的正式口徑:

```text
Authorization = "AILE " + tenantIntegrationId + ":" + signature
```

---

## 五、包結構設計(DDD 分層)

```text
aile-service/
  aile-service-platform-integration/
    src/main/java/com/aile/service/platformintegration/
      PlatformIntegrationApplication.java
      config/
      gateway/                          ← 网关层(新增,本服务核心入口)
        OpenApiRoutingFilter.java       ← /openapi/v1/* 路由
        SignatureVerifyFilter.java      ← 入站签名验签
        TenantContextResolver.java      ← 根据 tenantIntegrationId 解析租户上下文
        ProxyController.java            ← 代理转发
      controller/                       ← 自有接口层
        InstallController.java          ← /install /update /uninstall /rotate-secret
        IntegrationAppController.java   ← /admin/integrations/apps
        TenantIntegrationController.java
      application/                      ← 应用服务层
        InstallHandshakeAppService.java
        IntegrationManagementAppService.java
        EventPublishAppService.java
      domain/                           ← 领域层
        integrationapp/
          IntegrationApp.java
          IntegrationAppRepository.java
        tenantintegration/
          TenantIntegration.java
          TenantIntegrationRepository.java
          TenantIntegrationStatus.java
          TenantIntegrationAuditRepository.java
          InstallHandshakeService.java
        tenantmapping/
          TenantMapping.java
          TenantMappingRepository.java
        event/
          EventEnvelope.java
          StandardEventType.java
          EventPublisher.java           ← 接口,实现走 Pub/Sub
          EventLogRepository.java
        signature/
          HmacSignatureService.java
      infrastructure/                   ← 基础设施层
        persistence/
          IntegrationAppMongoRepo.java
          TenantIntegrationMongoRepo.java
          TenantIntegrationAuditMongoRepo.java
          TenantMappingMongoRepo.java
          EventLogMongoRepo.java
        pubsub/
          EventPubSubPublisher.java     ← Pub/Sub 实现
        http/
          InstallHttpClient.java        ← 调用 AIPower /install
        proxy/
          DownstreamRouteRegistry.java  ← /openapi/v1/* 路由配置
```

API 模型層(與其他服務共享):

```text
aile-api/
  aile-platform-integration-api/
    src/main/java/com/aile/api/platformintegration/
      model/
        IntegrationAppModel.java
        TenantIntegrationModel.java
        TenantMappingModel.java
      enums/
        IntegrationAppStatus.java
        TenantIntegrationStatus.java
        StandardEventType.java
        OwnerType.java
      dto/
        InstallRequestDto.java
        InstallResponseDto.java
        UpdateInstallRequestDto.java
        UninstallRequestDto.java
        RotateSecretRequestDto.java
        EventEnvelopeDto.java
```

---

## 六、領域模型設計

### 6.1 IntegrationApp(聚合根)

平臺級全域性應用定義。一個生態應用在全平臺僅一條記錄。

```java
@Document("aile.platform.integration.app")
@CompoundIndexes({
    @CompoundIndex(name = "appId_1", def = "{'appId': 1}", unique = true)
})
public class IntegrationAppModel extends BaseModel {
    private String appId;                  // integrationAppId,如 "aipower"
    private String appName;
    private String provider;               // 如 "AIPOWER"
    private String installBaseUrl;         // 固定安装入口地址(控制面)
    private List<TenantType> supportedTenantTypes; // 支持的租户类型,例如 [PERSONAL, TEAM]
    private List<String> supportedScopes;  // 占位,本期默认 ["*"]
    private List<String> defaultScopes;    // 占位,本期默认 ["*"]
    private List<String> supportedEvents;  // 支持的事件类型清单(资源域级,如 contact.*)
    private IntegrationAppStatus status;   // ACTIVE / DEPRECATED
}
```

### 6.2 TenantIntegration(聚合根,含狀態機)

租戶安裝例項。同一 `aileTenantId + appId` 只能存在一個非 `DELETED` 記錄。

```java
@Document("aile.platform.tenant.integration")
@CompoundIndexes({
    @CompoundIndex(name = "integrationId_1", def = "{'integrationId': 1}", unique = true),
    @CompoundIndex(name = "tenant_app_active",
                   def = "{'aileTenantId': 1, 'appId': 1, 'status': 1}")
})
public class TenantIntegrationModel extends BaseModel {
    private String integrationId;          // tenantIntegrationId
    private String aileTenantId;
    private TenantType aileTenantType;     // PERSONAL / TEAM
    private String appId;                  // 关联 IntegrationApp.appId
    private String appSecret;              // tenantIntegrationSecret,明文存储(本期沿用现状)
    private IntegrationMode integrationMode; // PERSONAL / TEAM
    private String apiBaseUrl;             // 占位,本期不调用
    private String webhookUrl;             // 统一 Webhook 接收地址,业务事件与 notice.* 通知事件全部投递到此(由独立 webhook 服务消费 Pub/Sub 后投递);接收侧自行按 eventType 分发
    private List<String> grantedScopes;    // 占位,本期默认 ["*"]
    private List<String> subscribedEvents; // 资源域级订阅,如 ["contact.*", "user.*"]
    private TenantIntegrationStatus status;
    private String createdBy;
    // 审计日志不内嵌,使用独立 collection aile.platform.tenant.integration.audit
}
```

**狀態機**:

| 狀態 | 含義 | 本期是否啟用 |
| --- | --- | --- |
| PENDING | Aile 已建立安裝草稿,握手未完成 | 是 |
| PENDING_USER_CONFIRM | AIPower 側需人工確認 | 列舉保留,不會進入此狀態 |
| ACTIVE | 安裝完成,正常執行 | 是 |
| SUSPENDED | 臨時停用 | 是 |
| DISABLED | 租戶/平臺主動停用 | 是 |
| DELETED | 已解除安裝,終態 | 是 |

狀態遷移圖:

```mermaid
stateDiagram-v2
    [*] --> PENDING
    PENDING --> ACTIVE
    PENDING --> DELETED
    ACTIVE --> SUSPENDED
    ACTIVE --> DISABLED
    ACTIVE --> DELETED
    SUSPENDED --> ACTIVE
    SUSPENDED --> DISABLED
    SUSPENDED --> DELETED
    DISABLED --> ACTIVE
    DISABLED --> DELETED
    DELETED --> [*]
```

**關鍵規則**:

- 非 `ACTIVE` 狀態不投遞事件,不接受簽名 API 呼叫(解除安裝/輪轉等控制面動作除外)
- 所有狀態遷移須寫入獨立審計集合(見 6.4)
- 同一 `aileTenantId + appId` 組合只能存在一個非 `DELETED` 記錄(應用層校驗 + 唯一索引)
- `DELETED` 為終態,清理期結束後可物理清除 `appSecret` 與端點資訊

### 6.3 TenantMapping(實體)

跨系統租戶對映。

```java
@Document("aile.platform.tenant.mapping")
public class TenantMappingModel extends BaseModel {
    private String mappingId;
    private String aileTenantId;
    private TenantType aileTenantType;    // PERSONAL / TEAM
    private String externalTenantId;      // 外部生态应用侧租户 ID(如 AIPower 内部租户 ID)
    private String externalSpaceId;       // 团队模式空间ID,个人模式为 null
    private OwnerType ownerType;          // AILE_PERSONAL / AILE_TEAM
    private String ownerId;               // 个人模式必填,= ailePersonalTenantId
    private String integrationId;         // 关联 TenantIntegration
    private MappingStatus status;
}
```

**OwnerType 列舉**:

```java
public enum OwnerType {
    AILE_PERSONAL,  // 个人租户模式
    AILE_TEAM       // 团队租户模式(预留,实际团队模式可不依赖 ownerId)
}
```

**個人租戶 / 團隊租戶業務前提**:

- 個人租戶在 Aile 側**沿用團隊租戶的資料結構**,前端按功能粒度做能力限制,以便未來一鍵升級為團隊租戶
- **一個賬號僅擁有且必擁有一個個人租戶**——賬號↔個人租戶為一對一繫結關係,不存在多賬號共享同一個人租戶的場景
- 團隊租戶可包含多個賬號與多個空間(space)

**對映規則**:

- **個人租戶**
    - AIPower 側**不為每個 Aile 個人租戶單獨建立外部租戶例項**,所有 Aile 個人租戶共用同一 `externalTenantId`(=publicTenantId)
    - `ownerType = AILE_PERSONAL`
    - `ownerId = ailePersonalTenantId`(= 該賬號 ID,三者一一對應),作為 AIPower 共享租戶內的資料隔離鍵
- **團隊租戶** → 每個團隊獨立 `externalTenantId`,可選 `externalSpaceId`
- 對映在安裝握手成功後根據 AIPower 返回資訊建立
- 對映表是事件路由與資料隔離的核心依據

**Contact ↔ ServiceNumber 關係約定**(本服務不直接持有 contact / visitor / service_number 領域模型,這些保留在 auth / tenant 等下游服務,但其關係約束直接影響事件 scope 與 OpenAPI 路徑設計,在此明確口徑):

- **service_number(服務號,ServiceNumber)** 是租戶內資源,一個租戶可擁有 1~N 個服務號,服務號本身歸屬唯一租戶。
- **contact / visitor(客戶/訪客)** 是租戶級主體,在租戶範圍內全域性唯一(visitor 實名歸戶後晉升為 contact)。
- **「進線」是 contact × service_number 的關係實體**——同一個 contact 可以進入同租戶下的 1~N 個服務號,在每個服務號下獨立呈現為「該服務號的客戶」。
- 由此推論:
    - 主體生命週期(contact.created / contact.updated / contact.deleted)是租戶級事件,不帶 `scope.serviceNumberId`。
    - 進線 / 關注變更(contact.entered / contact.service_number_followed / contact.service_number_unfollowed)是服務號級事件,必填 `scope.serviceNumberId`;`followed` / `unfollowed` 表達客戶主動關注 / 取消關注該服務號的狀態變遷。
    - OpenAPI 路徑上 contact / visitor 資源始終巢狀在 `/openapi/v1/service-numbers/&#123;snId&#125;/` 之下,因為外部生態應用看到的「客戶」始終是某個服務號下的檢視(詳見 §9.2)。

### 6.4 TenantIntegrationAudit(審計實體,獨立 collection)

```java
@Document("aile.platform.tenant.integration.audit")
@CompoundIndexes({
    @CompoundIndex(name = "integrationId_time",
                   def = "{'integrationId': 1, 'occurredAt': -1}")
})
public class TenantIntegrationAuditModel extends BaseModel {
    private String integrationId;
    private TenantIntegrationStatus fromStatus;
    private TenantIntegrationStatus toStatus;
    private String actor;        // userId 或 system 标识
    private String reason;
    private Long occurredAt;
}
```

僅追加寫入,不更新不刪除,保留完整狀態遷移歷史。

---

## 七、安裝握手協議

本節定義 Aile 主導、外部生態應用配合的安裝握手全流程,涵蓋建立、更新、解除安裝、金鑰輪換四個控制面動作。

### 7.1 安裝時序

```mermaid
sequenceDiagram
    participant Admin as "Aile 租戶管理員"
    participant PI as "platform-integration"
    participant App as "TenantIntegration 安裝草稿"
    participant AIPower as "AIPower installBaseUrl"
    participant Mapping as "TenantMapping"

    Admin->>PI: 啟用 AIPower
    PI->>App: 建立 PENDING 草稿
    PI->>App: 生成 tenantIntegrationId / tenantIntegrationSecret
    PI->>AIPower: POST {installBaseUrl}/install (HTTPS + IP 白名單)
    AIPower->>AIPower: 判斷 PERSONAL / TEAM 模式
    AIPower->>AIPower: 建立/繫結內部租戶上下文
    AIPower-->>PI: 返回 integrationMode / apiBaseUrl / webhookUrl / externalTenantId / ownerType / ownerId
    PI->>Mapping: 建立 TenantMapping
    PI->>App: 狀態遷移至 ACTIVE + 寫審計
    PI-->>Admin: 安裝完成
```

### 7.2 `/install` 請求欄位

請求:`POST &#123;installBaseUrl&#125;/install`

| 欄位 | 必填 | 說明 |
| --- | --- | --- |
| `integrationAppId` | 是 | 全域性應用 ID,如 `aipower` |
| `tenantIntegrationId` | 是 | 本次安裝的租戶例項 ID |
| `tenantIntegrationSecret` | 是 | 與上述 ID 配對的簽名金鑰,僅在安裝握手階段下發 |
| `aileTenantId` | 是 | Aile 租戶 ID |
| `aileTenantType` | 是 | PERSONAL / TEAM |
| `requestedScopes` | 是 | 本期固定為 ["*"] |
| `aileApiBaseUrl` | 是 | AIPower 後續呼叫 Aile Open API 的基礎地址 |
| `installNonce` | 是 | 冪等鍵,AIPower 據此去重同一安裝請求 |
| `installedAt` | 是 | ISO-8601 時間戳 |

**安全說明**:本期 `/install` **不攜帶簽名**,僅依賴 HTTPS + AIPower 側 IP 白名單保障來源合法性。`tenantIntegrationSecret` 在請求體中明文下發(HTTPS 傳輸層已加密)。

### 7.3 `/install` 響應欄位

| 欄位 | 必填 | 說明 |
| --- | --- | --- |
| `integrationMode` | 是 | PERSONAL / TEAM |
| `apiBaseUrl` | 是 | 佔位,本期儲存但不呼叫 |
| `webhookUrl` | 是 | 統一 Webhook 接收地址,業務事件與 notice.* 通知事件均投遞到此(由獨立 webhook 服務消費 Pub/Sub 後投遞),必須 HTTPS;第三方在自己 handler 內按 `eventType` 欄位分發到對應處理邏輯 |
| `externalTenantId` | 是 | 外部生態應用側租戶 ID(如 AIPower 內部租戶 ID) |
| `externalSpaceId` | 否 | 團隊模式可選,個人模式省略 |
| `ownerType` | 個人租戶必填 | AILE_PERSONAL |
| `ownerId` | 個人租戶必填 | ailePersonalTenantId |
| `acceptedScopes` | 是 | 本期固定回傳 ["*"] |
| `installStatus` | 是 | 本期僅接受 ACTIVE,返回其他值視為錯誤 |

### 7.4 錯誤碼

| 錯誤碼 | HTTP | 說明 |
| --- | --- | --- |
| `UNSUPPORTED_TENANT_TYPE` | 400 | AIPower 不支援該 aileTenantType |
| `DUPLICATE_INSTALL` | 409 | 同 installNonce 或同 (aileTenantId + integrationAppId) 已存在有效安裝 |
| `INTEGRATION_APP_NOT_FOUND` | 404 | integrationAppId 未註冊或非 ACTIVE |
| `STATUS_TRANSITION_FORBIDDEN` | 409 | 不允許的狀態遷移 |
| `SIGNATURE_INVALID` | 401 | Open API 簽名驗籤失敗 |
| `TENANT_INTEGRATION_NOT_ACTIVE` | 403 | 非 ACTIVE 狀態下呼叫需簽名的 Open API |
| `INVALID_WEBHOOK_URL` | 400 | 非 HTTPS URL |

保留但本期不觸發:`SCOPE_NOT_GRANTABLE`(scope 未啟用)、`REQUIRES_MANUAL_APPROVAL`(人工審批未啟用)。

### 7.5 其他控制面介面

| 介面 | 說明 |
| --- | --- |
| `POST &#123;installBaseUrl&#125;/update` | 更新已安裝租戶的 webhook 端點等 |
| `POST &#123;installBaseUrl&#125;/uninstall` | 解除安裝,本服務側 TenantIntegration → DELETED |
| `POST &#123;installBaseUrl&#125;/rotate-secret` | 金鑰輪換,**硬切換**,舊金鑰立即失效,無重疊期 |

---

## 八、簽名與驗籤

### 8.1 統一簽名規則

```text
raw = tenantIntegrationId + nonce + bodyString
signature = Base64(HmacSHA256(tenantIntegrationSecret, raw))
Authorization = "AILE " + tenantIntegrationId + ":" + signature
```

統一適用範圍(消除當前 SignatureUtils 三套不一致):

- `/openapi/v1/*` 入站驗籤
- 控制面 `/install` 之後所有需鑑權呼叫
- 測試介面

**不參與簽名的欄位**:`serviceNumberId` 僅作為 OpenAPI 路徑上的資源標識與 EventEnvelope 的 scope 欄位使用,不進入簽名 raw 串;EventEnvelope 的 `scope` 子結構整體同樣不參與任何簽名計算。簽名 raw 串仍嚴格遵循 §8.1 第一段定義。

### 8.2 HmacSignatureService

```java
public interface HmacSignatureService {
    String sign(String body, String tenantIntegrationId, String secret, String nonce);
    boolean verify(String body, String authHeader, String nonce, String secret);
    String buildAuthHeader(String tenantIntegrationId, String signature);
    AuthHeaderParts parseAuthHeader(String authorizationHeader);
}
```

### 8.3 Nonce 與防重放

**本期不實現 nonce 防重放**(沿用現狀)。`nonce` 僅作為簽名輸入引數參與計算,不做快取查重、不校驗時間戳視窗。後續如有安全需求再加 Redis 快取 + TTL。

### 8.4 Secret 儲存

`tenantIntegrationSecret` **明文儲存在 Mongo**,與當前 `TenantAppModel.secretKey` 一致。已知風險點,本期不升級,後續如啟用 KMS 再做遷移。

---

## 九、`/openapi/v1/*` 閘道器層

本服務承擔所有生態應用的入站 API 統一入口。

### 9.1 閘道器職責與請求流轉

```mermaid
flowchart LR
    App["生態應用<br/>AIPower 等"] -->|AILE tenantIntegrationId:sig| F1["SignatureVerifyFilter"]
    F1 --> F2["TenantContextResolver"]
    F2 --> F3["OpenApiRoutingFilter"]
    F3 --> P["ProxyController"]
    P --> S1["tenant 服務"]
    P --> S2["auth 服務"]
    P --> S3["job 服務"]
    P --> S4["room 服務"]
    P --> S5["account 服務"]
```

處理順序:

1. **SignatureVerifyFilter**:解析 `Authorization` header,提取 `tenantIntegrationId`,查庫取 `tenantIntegrationSecret`,按§8.1 驗籤,失敗返回 401 `SIGNATURE_INVALID`
2. **TenantContextResolver**:根據 `tenantIntegrationId` 還原 `aileTenantId / aileTenantType / externalTenantId / ownerType / ownerId`,注入請求上下文
3. **OpenApiRoutingFilter**:根據路徑查 `DownstreamRouteRegistry`,判斷應路由到哪個內部服務
4. **ProxyController**:代理轉發(WebClient/RestTemplate),帶上呼叫側服務間鑑權(複用現有 SystemAppModel 簽名機制),將上下文以 header 的形式傳遞給下游(`X-Aile-Tenant-Id` / `X-Aile-Owner-Id` 等)
5. **領域邏輯完全在下游服務**處理,本服務不解析 body 內容

### 9.2 路由清單(具體 method + path 白名單)

`DownstreamRouteRegistry` 採用**配置驅動 + 白名單**:每條 Open API 必須在下表顯式註冊具體 `method + path`,不接受萬用字元字首;未登記路徑一律返回 404 `ROUTE_NOT_FOUND`。新增 API 時,本服務的路由配置與下游服務實現必須同步登記。

#### 9.2.1 租戶級資源(扁平路徑)

| 方法 + 路徑 | 下游服務 | 資源域 | 說明 |
| --- | --- | --- | --- |
| `GET /openapi/v1/tenants/me` | aile-service-tenant | 租戶 | 查詢當前安裝例項所屬租戶資訊 |
| `GET /openapi/v1/users` | aile-service-account | 使用者 | 列出租戶下使用者(分頁) |
| `GET /openapi/v1/users/&#123;userId&#125;` | aile-service-account | 使用者 | 查詢單個使用者 |
| `GET /openapi/v1/service-numbers` | aile-service-tenant | 服務號 | 列出當前 integration 可訪問的全部服務號 |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;` | aile-service-tenant | 服務號 | 查詢單個服務號詳情 |
| `GET /openapi/v1/groups` | aile-service-room | 群組 | 列出租戶群組(分頁) |
| `GET /openapi/v1/groups/&#123;groupId&#125;` | aile-service-room | 群組 | 查詢單個群組 |
| `GET /openapi/v1/addressbook` | aile-service-auth | 通訊錄 | 查詢租戶通訊錄 |
| `POST /openapi/v1/aiff/configurations` | aile-service-auth | AIFF 配置 | 註冊 AIFF 槽位 + endpoint URL |
| `GET /openapi/v1/aiff/configurations` | aile-service-auth | AIFF 配置 | 查詢當前 integration 已註冊的 AIFF 配置清單 |
| `PUT /openapi/v1/aiff/configurations/&#123;configId&#125;` | aile-service-auth | AIFF 配置 | 更新單個 AIFF 配置 |
| `DELETE /openapi/v1/aiff/configurations/&#123;configId&#125;` | aile-service-auth | AIFF 配置 | 刪除單個 AIFF 配置 |
| `POST /openapi/v1/entry-sources` | aile-service-room | 進線原因 | **建立預設進線來源 EntrySource**;閘道器自動注入 `owner.integrationAppId` / `owner.tenantIntegrationId` 標記建立方;觸發事件按 owner 路由回建立方 webhook(詳 §9.4) |
| `GET /openapi/v1/entry-sources` | aile-service-room | 進線原因 | **列表查詢**(預設僅返回 `owner.tenantIntegrationId` = 當前 integration 建立的記錄;支援 `status` / `channel` / `reason.type` 過濾) |
| `GET /openapi/v1/entry-sources/&#123;sourceId&#125;` | aile-service-room | 進線原因 | **查詢單個 EntrySource**;owner 非當前 integration 時返回 403 `ENTRY_SOURCE_FORBIDDEN` |
| `PUT /openapi/v1/entry-sources/&#123;sourceId&#125;` | aile-service-room | 進線原因 | **更新 EntrySource**(name / `reason.data` / expiresAt / channels 等);owner 檢查同上 |
| `PUT /openapi/v1/entry-sources/&#123;sourceId&#125;/status` | aile-service-room | 進線原因 | **停用 / 重啟 EntrySource**(`active` ⇄ `inactive`);owner 檢查同上 |
| `GET /openapi/v1/business/objects` | aile-service-job | 業務物件 | 列出業務物件型別(擴充套件點) |
| `GET /openapi/v1/sync/resources` | aile-service-job | 同步資源 | 列出同步資源型別(擴充套件點) |

#### 9.2.2 服務號級資源(巢狀路徑 `/openapi/v1/service-numbers/&#123;snId&#125;/...`)

| 方法 + 路徑 | 下游服務 | 資源域 | 說明 |
| --- | --- | --- | --- |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/contacts` | aile-service-auth | 聯絡人 | 列出該服務號下的 contact(分頁 / 篩選) |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/contacts/&#123;contactId&#125;` | aile-service-auth | 聯絡人 | 查詢單個 contact 在該服務號下的檢視 |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/contacts/labels` | aile-service-auth | 聯絡人標籤 | **列出該服務號下可用的客戶標籤清單**(供整合方篩選受眾) |
| `POST /openapi/v1/service-numbers/&#123;snId&#125;/contacts/labels:add` | aile-service-auth | 聯絡人標籤 | **批次給 contact 打標籤**(body:`contactIds[]`  • `labelIds[]`,冪等) |
| `POST /openapi/v1/service-numbers/&#123;snId&#125;/contacts/labels:remove` | aile-service-auth | 聯絡人標籤 | **批次摘標籤**(body:`contactIds[]`  • `labelIds[]`,冪等) |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/contacts/&#123;contactId&#125;/labels` | aile-service-auth | 聯絡人標籤 | 查詢單個 contact 在該服務號下的全部標籤 |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/visitors` | aile-service-auth | 訪客 | 列出該服務號下的 visitor |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/visitors/&#123;visitorId&#125;` | aile-service-auth | 訪客 | 查詢單個 visitor 在該服務號下的檢視 |
| `POST /openapi/v1/service-numbers/&#123;snId&#125;/broadcasts` | aile-service-message | 群發任務 | **建立服務號群發任務**(複用 Aile 現行服務號群發能力,新增 OpenAPI 入口)。body:`name` / `note` / `channels[]` / `messages[]`(1..5 則 `NoticeContent` 卡片 / `&#123;type:"text"|"image"|"file", ...&#125;`) / `audience`(union:`&#123;type:"tags", tagIds[], tagOperator:"AND"|"OR"&#125;` 標籤篩選 或 `&#123;type:"contacts", contactIds[]&#125;` 名單直傳,後者單任務 ≤ **1000**) / `schedule`(`&#123;type:"immediate"&#125;` 或 `&#123;type:"scheduled", at&#125;`)。返回 `taskId` / `estimatedRecipientCount` |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/broadcasts/&#123;taskId&#125;` | aile-service-message | 群發任務 | **查詢任務狀態**。返回 `status`(`scheduled` / `sending` / `completed` / `cancelled` / `failed`) / `recipientCount` / `summary&#123;sent,delivered,failed,read&#125;` / `startedAt` / `finishedAt`。MVP 階段以輪詢替代任務級 webhook |
| `POST /openapi/v1/service-numbers/&#123;snId&#125;/broadcasts/&#123;taskId&#125;:cancel` | aile-service-message | 群發任務 | **取消任務**(僅 `scheduled` 狀態有效) |
| `GET /openapi/v1/service-numbers/&#123;snId&#125;/notices/&#123;noticeId&#125;` | aile-service-message | 通知 | 查詢單條通知詳情(狀態 / 渠道 / 接收方 / NoticeContent 快照) |

**路徑設計約定**:

- **頂層 `/openapi/v1/service-numbers`** 提供「列出當前 integration 可訪問的全部服務號」能力,生態應用據此發現並下鑽。
- **服務號級資源**(contacts / visitors / broadcasts / 服務號級 notices)統一以 `/openapi/v1/service-numbers/&#123;snId&#125;/...` 巢狀路徑表達;閘道器從 path variable 解析 `serviceNumberId`,以 header `X-Aile-Service-Number-Id` 透傳給下游領域服務。
- **租戶級資源**(tenants / users / groups / addressbook / aiff / entry-sources / business / sync)保持扁平路徑,不引入服務號字首。
- `serviceNumberId` 僅作為路由 / 上下文引數,**不參與簽名計算**(詳見 §8.1)。
- **群發場景**取代舊版「服務號級 notice 寫入」入口:整合方**不**透過 `/openapi/v1/service-numbers/&#123;snId&#125;/notices` 直接建立通知,而是統一透過上表 `POST .../broadcasts` 介面提交群發任務(單條傳送視為 audience 長度為 1 的群發任務),Aile 群發流水線完成命中 / 排程 / 退訂 / 頻次 / 時間窗治理後下發;`/notices/&#123;noticeId&#125;` 僅保留只讀查詢語義。
- 下游服務對映可隨架構演進調整,以 Spring `@ConfigurationProperties` 載入,避免硬編碼。

### 9.3 鑑權與狀態門控

- `tenantIntegrationId` 對應的 `TenantIntegration.status` 必須 = `ACTIVE`,否則返回 403 `TENANT_INTEGRATION_NOT_ACTIVE`
- `subscribedEvents` 僅用於事件過濾,**不參與入站 API 鑑權**(本期不啟用 scope)
- 對服務號級路徑(`/openapi/v1/service-numbers/&#123;snId&#125;/...`),閘道器需額外校驗:`tenantIntegrationId` 必須有權訪問該 `serviceNumberId`(即該 service_number 已繫結到當前 integration),校驗不透過返回 403 `SERVICE_NUMBER_FORBIDDEN`
- 對 EntrySource 資源(`/openapi/v1/entry-sources/&#123;sourceId&#125;`)的 GET / PUT / 狀態切換,閘道器在下游響應前由 `aile-service-room` 進行 owner 檢查:`EntrySource.owner.tenantIntegrationId` 必須 = 當前 `tenantIntegrationId`,否則返回 403 `ENTRY_SOURCE_FORBIDDEN`;列表查詢預設僅返回 owner 為當前 integration 的記錄
- 路由失敗返回 404 `ROUTE_NOT_FOUND`

### 9.4 EntrySource 整合規範(進線原因 owner 歸屬與 webhook 路由)

EntrySource(進線來源,詳見進線原因規格設計)是租戶級共享資源,但**每條記錄歸屬唯一建立方**(integration / Aile 後臺管理員)。本服務在閘道器層為 EntrySource 注入 owner 標記並據此完成事件迴流,使「**誰建立,誰接收**」成為整合契約的硬約束。

#### 9.4.1 EntrySource owner 欄位(由 aile-service-room 持久化)

閘道器在轉發 `POST /openapi/v1/entry-sources` 時,自動在請求體中注入 owner 子結構(整合方**不**自行填寫,即便填寫也會被覆蓋):

```json
{
  "owner": {
    "type": "integration",
    "integrationAppId": "aireach",
    "tenantIntegrationId": "ti_xxx"
  }
}
```

| 欄位 | 取值 | 說明 |
| --- | --- | --- |
| `owner.type` | `integration` / `aile_admin` | 透過 `/openapi/v1/entry-sources` 建立的固定為 `integration`;Aile 後臺管理臺手工建立的為 `aile_admin` |
| `owner.integrationAppId` | 當前 IntegrationApp.appId | 例 `aireach`;`aile_admin` 型別為 null |
| `owner.tenantIntegrationId` | 當前 TenantIntegration.integrationId | 用於精確路由 webhook 回撥;`aile_admin` 型別為 null |

EntrySource 在建立後,owner 欄位**不可修改**;解除安裝安裝例項(`TenantIntegration → DELETED`)時,該 integration 名下所有 EntrySource 由本服務級聯呼叫 `PUT .../status (inactive)` 停用,但不刪除記錄(保留歷史 Session 的進線原因可讀性)。

#### 9.4.2 EntrySource 觸發 → 事件迴流路由規則

當使用者透過某個 EntrySource 進線建立 Session,`aile-service-room` 按以下規則路由 `session.*` 事件:

```mermaid
flowchart LR
    Click["使用者透過 EntrySource 進線"] --> Room["aile-service-room<br/>讀 EntrySource.owner"]
    Room -->|owner.type=integration| EP["EventPublishClient → 本服務"]
    Room -->|owner.type=aile_admin| Skip["不發整合 webhook<br/>(僅寫內部 Session 流轉)"]
    EP --> Env["封裝 EventEnvelope<br/>integration.integrationId = EntrySource.owner.tenantIntegrationId"]
    Env --> Pub["Pub/Sub Topic"]
    Pub --> WH["獨立 Webhook 服務"]
    WH -->|按 integrationId 查 TenantIntegration.webhookUrl| App["建立該 EntrySource 的整合方 webhookUrl"]
```

核心約束:

- **`integration.integrationId` 來源於 EntrySource.owner**,而非「觸發使用者當前所屬租戶的全部 integration」——多個 integration 安裝到同一租戶時,**只有建立該 EntrySource 的 integration 會收到迴流**,其它 integration 不感知。
- 若該 `tenantIntegrationId` 已不在 `ACTIVE` 狀態(SUSPENDED / DISABLED / DELETED),按 §6.2 狀態機規則不投遞,事件僅寫 EventLog 備查(`publishStatus = FAILED`,`failureReason = "OWNER_INTEGRATION_NOT_ACTIVE"`)。
- `owner.type = aile_admin` 的 EntrySource 觸發時**不發整合 webhook**,僅在 Aile 內部產生 Session 流轉。

#### 9.4.3 新增 `session.*` 資源域

為支撐上述迴流鏈路,本期 §10.2 事件清單新增 `session.*` 資源域(由 `aile-service-room` 在 EntrySource 觸發 Session 時發出),清單詳見 §10.2。

#### 9.4.4 整合方接入約定

- 整合方透過 `POST /openapi/v1/entry-sources` 建立 EntrySource 時,**無需**單獨配置 webhook URL —— 事件統一走該 integration 安裝時握手協議給出的 `webhookUrl`,無第二條投遞通道。
- 整合方需在 `TenantIntegration.subscribedEvents` 中訂閱 `session.*` 資源域,否則即使 owner 匹配也不會收到迴流(本服務在釋出前按 `subscribedEvents` 過濾)。
- 單條 EntrySource 由單一 integration 擁有(1:1);如需多 integration 共享同一進線入口,各自建立獨立 EntrySource 並指向同一目標渠道即可。

---

## 十、事件體系

### 10.1 EventEnvelope 結構

統一事件信封結構如下,所有 Aile 領域服務發出的事件都必須封裝為此結構後入 Pub/Sub:

```json
{
  "eventId": "evt_xxx",
  "eventType": "contact.entered",
  "eventVersion": "1.0",
  "occurredAt": "2026-05-20T10:00:00Z",
  "source": "aile-service-auth",
  "integration": {
    "appId": "aipower",
    "integrationId": "ti_xxx"
  },
  "tenant": {
    "aileTenantId": "t_xxx",
    "aileTenantType": "PERSONAL",
    "mappedExternalTenantId": "aip_xxx",
    "mappedExternalSpaceId": null,
    "ownerType": "AILE_PERSONAL",
    "ownerId": "pt_xxx"
  },
  "scope": {
    "serviceNumberId": "sn_xxx"
  },
  "data": { ... },
  "metadata": { ... }
}
```

關鍵設計點:

- `source` 為實際發出事件的領域服務名(不再寫死為 `aile`)
- `tenant` 子結構同時包含 Aile 側與外部生態側兩個身份,供下游 webhook 服務路由到正確生態應用租戶
- `scope` 子結構承載「業務作用域」語義,與 `tenant`(身份維度)嚴格分離;本期只包含 `serviceNumberId`,後續可擴充套件 `channelType` 等橫切維度
- `scope.serviceNumberId` 在**服務號級事件**中必填,在**租戶級事件**中省略——具體分類見 §10.2
- `eventVersion` 事件 schema 變更時遞增
- `scope` 子結構及其內的 `serviceNumberId` **均不參與簽名計算**(詳見 §8.1)

### 10.2 事件型別清單(資源域級)

本期本服務需支援封裝與釋出以下資源域:

| 資源域 | 中文說明 | 舉例事件型別 | 發出服務 | scope.serviceNumberId |
| --- | --- | --- | --- | --- |
| `tenant.*` | 租戶生命週期(Aile 側租戶本身的建立、修改、停用) | `tenant.created` / `tenant.updated` / `tenant.disabled` | aile-service-tenant | 不填(租戶級) |
| `user.*` | 使用者 / 賬號(租戶下的成員,跨服務號共享) | `user.created` / `user.updated` / `user.disabled` | aile-service-account | 不填(租戶級) |
| `service_number.*` | 服務號(ServiceNumber,租戶內的客戶進線入口 / 業務號;一個租戶可擁有 1~N 個) | `service_number.created` / `service_number.updated` / `service_number.deleted` | aile-service-tenant | 必填(= 主體 ID,自身即服務號) |
| `contact.*` | 聯絡人 / 客戶(實名歸戶後的穩定主體,租戶範圍全域性唯一) | 主體生命週期(租戶級):`contact.created` / `contact.updated` / `contact.deleted`
進線 / 關注行為(服務號級):`contact.entered` / `contact.re_entered` / `contact.service_number_followed` / `contact.service_number_unfollowed` | aile-service-auth | 主體生命週期類不填;進線 / 關注行為類必填 |
| `visitor.*` | 訪客(未實名的臨時主體,可被合併 / 晉升為 contact) | 主體級:`visitor.created` / `visitor.merged`
進線行為(服務號級):`visitor.entered` | aile-service-auth | 主體級不填;進線行為類必填 |
| `group.*` | 群組 / 聊天室(租戶級,可跨服務號組織) | `group.created` / `group.member_changed` | aile-service-room | 不填(租戶級,群組跨服務號) |
| `addressbook.*` | 通訊錄(租戶成員 / 聯絡人組織目錄同步) | `addressbook.synced` | aile-service-auth | 不填(租戶級) |
| `notice.*` | 通知狀態(送達 / 失敗 / 已讀 / 點選 / 退回 / 投訴 / 任務完成 / 轉化 等狀態回撥) | **基礎(v1.0)**:`notice.delivered` / `notice.failed` / `notice.read`
**v1.3 推播場景閉環擴充套件**:`notice.clicked`(連結點選) / `notice.bounced`(投遞失敗子類:號碼無效 / 拒收 / 通道故障) / `notice.complained`(使用者標記騷擾) / `notice.task_completed`(整批傳送任務終態) / `notice.converted`(Aile 側歸因轉化) | aile-service-message | 通知繫結具體服務號時必填;租戶級通知可省略 |
| `session.*` | 會話生命週期(由 EntrySource 觸發的客戶會話)。**按 EntrySource owner 精確路由**回建立該來源的 integration,而非廣播給同租戶全部 integration(詳 §9.4) | `session.created` / `session.closed` / `session.transferred` | aile-service-room | EntrySource 繫結具體服務號時必填;租戶級 EntrySource 觸發時可省略 |

**事件分類口徑**:

- **租戶級事件**:主體跨服務號共享,`scope.serviceNumberId` 省略。包含 tenant / user / addressbook / group 全量,以及 contact / visitor 的主體生命週期事件。
- **服務號級事件**:行為發生在某個具體服務號上下文,`scope.serviceNumberId` 必填。包含 contact / visitor 的進線、關注 / 取消關注事件,以及繫結到具體服務號的 notice。
- **進線行為**單獨建模為 `contact.entered` / `visitor.entered` 等獨立事件型別,正確表達 contact ↔ service_number 的 N:M 關係——同一 contact 進入第二個服務號時,**不**觸發 contact.created,而是觸發 contact.entered。
- EventPublishClient 在封裝時按 eventType 自動校驗 `scope.serviceNumberId` 是否符合上述約束;違反約束直接拋錯,避免下游接收側得到含義不明的事件。

**投遞約定**:下游 webhook 服務統一投遞到 `TenantIntegration.webhookUrl`,**不再按事件型別分流**——本期合併 URL,所有資源域事件(含 notice.*)共用同一入口。接收側在自己 webhook handler 內根據 EventEnvelope 的 `eventType` 欄位分發到對應處理邏輯(業務異動 / 通知狀態 / 等)。

**`NoticeContent` 訊息契約與 `notice.clicked` 觸發規則(v1.4 補充)**

Aile 的「通知」是一種**結構化富文字訊息格式**(本服務不持有該領域邏輯,由 aile-service-message 承載),其傳送體(`NoticeContent`)由生態應用**逐條組合後提交**,**Aile 不維護預審模板**。為使 `notice.clicked` 事件能被正確路由回傳送應用,並與「進線」聯動,`NoticeContent` 與事件 payload 需遵以下約定:

1. **`NoticeContent` 結構**:`title` / `image` / `describe` / `buttons` (0..N)。每個 `button` 含 `buttonId` (傳送方給定、語義化) / `label` / `actionType` / `payload`。
2. **`actionType` 列舉**:`postback` / `url` / `aiff` / `action`。點選捕獲路徑差異:
    - `postback`: 客戶端 → aile-service-message 後端 postback 端點 → 解析 `payload`。
    - `url`: aile-service-message 包一層跳轉代理(形如 `/notice-click/&#123;noticeId&#125;/&#123;buttonId&#125;`)記錄點選 → 302 至目標 URL。
    - `aiff` / `action`: 由 AileDesktop 客戶端攔截點選後埋點上報 aile-service-message。
3. **`postback button.payload` 契約**(本服務與傳送應用雙方約束):
    - `appId` (string, **必填**):傳送方 IntegrationApp 的 `integrationAppId`,aile-service-message 據此反查該服務號下哪個 `TenantIntegration` 需被通知。
    - `clickId` (string, **必填**):傳送方自定義業務語義標識(例:`campaign_42_cta_redeem`)。所有其他 actionType 的 `payload` 僅供 Aile 本身使用,不作路由依據。
    - `extra` (object, 可選):業務自定義欄位,原樣透傳回 webhook。
4. **`notice.clicked` event payload(`EventEnvelope.data` 子結構)**必含:
    - `noticeId` / `buttonId` / `actionType` / `contactId` / `clickedAt`
    - `postbackPayload`:原樣透傳傳送方在 NoticeContent 中給定的按鈕 payload(含 `appId` / `clickId` / `extra`)
    - `inbound` (可選子結構,**由 Aile 側進線規則決定是否攜帶**):`&#123; triggered: boolean, conversationId?: string, reason?: &#123; code, label, source &#125; &#125;`
5. **點選 ↔ 進線聯動由 Aile 編排**:aile-service-message 攔截點選 → 依賴 aile-service-room 提供的進線規則(按 `serviceNumberId` / `actionType` / `payload.clickId` 等判據是否同時建立 / 複用 contact↔serviceNumber 進線) → 如命中,將 `inbound` 子結構填充入 event payload → EventPublishClient 封裝 EventEnvelope 交由本服務入 Pub/Sub。整合方接收一條 `notice.clicked` event 即可同時獲取「點選事實 + 進線事實 + 進線原因」。
6. **`inbound.reason.code`**推薦格式:`notice_click_<clickId>` 或由 Aile 運營配置的對映表生成。

> 本服務不新增領域邏輯,僅在「事件封裝 + 釋出」環節保證如上結構被原樣以 `EventEnvelope.data` 透傳。`NoticeContent` 結構的儲存 / 點選捕獲路由 / 進線規則引擎均由 aile-service-message + aile-service-room 實現。
> 

### 10.3 事件三段鏈路

```mermaid
flowchart LR
    DS["領域服務<br/>(auth / room / tenant ...)"] -->|發出原始事件| PI["platform-integration<br/>EventPublishAppService"]
    PI -->|封裝 EventEnvelope| Pub["EventPubSubPublisher"]
    Pub --> Topic["Pub/Sub Topic"]
    Pub --> Log["EventLog (僅審計)"]
    Topic --> WH["獨立 webhook 服務<br/>(消費+重試+DLQ)"]
    WH -->|HTTPS POST + 簽名| App["生態應用 webhook URL"]
```

**職責邊界**:

- 本服務只負責**封裝 + 釋出 + 寫 EventLog**
- **不負責**重試/DLQ/最終投遞
- EventLog **僅供審計與排障**,不作為重試源
- 上游領域服務接入方式:在該服務的應用服務層呼叫 `EventPublishClient`(api 模組提供的 FeignClient)

### 10.4 EventLog 儲存

```java
@Document("aile.platform.event.log")
@CompoundIndexes({
    @CompoundIndex(name = "eventId_1", def = "{'eventId': 1}", unique = true),
    @CompoundIndex(name = "tenant_time",
                   def = "{'aileTenantId': 1, 'occurredAt': -1}"),
    @CompoundIndex(name = "ttl_idx",
                   def = "{'occurredAt': 1}",
                   expireAfterSeconds = 2592000)  // 30 天 TTL
})
public class EventLogModel extends BaseModel {
    private String eventId;
    private String eventType;
    private String integrationId;
    private String aileTenantId;
    private String envelopeJson;     // 完整 EventEnvelope JSON 存档
    private PublishStatus publishStatus; // PUBLISHED / FAILED
    private String failureReason;
    private Long occurredAt;
}
```

- TTL = 30 天,到期自動清理
- `publishStatus = FAILED` 表示推入 Pub/Sub 本身失敗(僅本服務職責範圍),與下游 webhook 服務的重試狀態無關
- 僅供查詢/審計,不提供重投介面(重投需走下游 webhook 服務)

---

## 十一、管理面介面

### 11.1 IntegrationApp 管理(平臺管理臺手工建立)

| 介面 | 說明 |
| --- | --- |
| `POST /admin/integrations/apps` | 建立生態應用定義 |
| `GET /admin/integrations/apps` | 列表查詢 |
| `GET /admin/integrations/apps/&#123;appId&#125;` | 查詢單個 |
| `PUT /admin/integrations/apps/&#123;appId&#125;` | 更新(僅名稱/描述/事件清單等可變欄位) |
| `POST /admin/integrations/apps/&#123;appId&#125;/deprecate` | 下架,狀態 → DEPRECATED |

### 11.2 TenantIntegration 管理

| 介面 | 說明 |
| --- | --- |
| `POST /admin/integrations/tenant-integrations` | 啟動安裝握手(走§7 協議) |
| `GET /admin/integrations/tenant-integrations` | 列表查詢(支援按 aileTenantId / appId / status 篩選) |
| `GET /admin/integrations/tenant-integrations/&#123;integrationId&#125;` | 詳情 |
| `POST /admin/integrations/tenant-integrations/&#123;integrationId&#125;/suspend` | ACTIVE → SUSPENDED |
| `POST /admin/integrations/tenant-integrations/&#123;integrationId&#125;/resume` | SUSPENDED/DISABLED → ACTIVE |
| `POST /admin/integrations/tenant-integrations/&#123;integrationId&#125;/disable` | → DISABLED |
| `POST /admin/integrations/tenant-integrations/&#123;integrationId&#125;/uninstall` | → DELETED + 呼叫下游 /uninstall |
| `POST /admin/integrations/tenant-integrations/&#123;integrationId&#125;/rotate-secret` | 硬切換金鑰 |
| `GET /admin/integrations/tenant-integrations/&#123;integrationId&#125;/audits` | 查詢審計軌跡 |

### 11.3 鑑權

- **複用現有使用者登入體系**(同 Aile 後臺管理臺),讀取當前登入使用者身份
- 僅**平臺管理員**角色可訪問 `/admin/integrations/apps/**`(生態應用定義)
- 租戶管理員可訪問本租戶的 `/admin/integrations/tenant-integrations/**`
- 本服務不重複實現鑑權,複用 aile-service-auth 提供的 RBAC 攔截器

---

## 十二、資料遷移與上線

### 12.1 遷移範圍

當前 aile-service-job 中的 `TenantAppModel` 是本服務未拆分前的簡化實現,由本服務上線後**一次性遷移並下線**。

| 舊實體/介面 | 新位置 | 處理方式 |
| --- | --- | --- |
| `TenantAppModel`(aile-service-job) | `TenantIntegrationModel`(本服務) | 一次性指令碼遷移存量資料 |
| `/tenantapp/v1/*` 介面 | `/admin/integrations/tenant-integrations`  • `/openapi/v1/*` | 舊介面直接下線(不提供相容期) |
| `WebhookEventServiceImpl`(aile-service-job) | `EventPublishAppService`(本服務) | 原傳送點改為傳送到本服務 EventPublishClient |
| SignatureUtils 三套邏輯(各服務) | `HmacSignatureService`(本服務 api 模組) | 各服務按§8.1 統一呼叫 |
| `SystemAppModel`(aile-service-job) | 不動,保留原位 | 內部服務間簽名繼續使用,與 IntegrationApp 互不干涉 |

### 12.2 遷移指令碼

```jsx
// 偽程式碼
for each tenantApp in aile-service-job.TenantAppModel {
    create IntegrationApp if not exists (provider=AIPOWER → appId=aipower)
    create TenantIntegration {
        integrationId = tenantApp.appId
        tenantIntegrationSecret = tenantApp.secretKey  // 明文沿用
        aileTenantId = tenantApp.tenantId
        appId = "aipower"
        webhookUrl = tenantApp.webhookUrl   // 旧 tenantApp.noticeUrl 不再使用,合并至唯一 webhookUrl;迁移后由租户管理员确认是否需改成 noticeUrl
        status = ACTIVE
        subscribedEvents = ["*"]  // 存量按全订阅处理
    }
    create TenantMapping {
        aileTenantId = tenantApp.tenantId
        externalTenantId = tenantApp.aipowerTenantId
        ownerType = AILE_PERSONAL  // 存量推断
        ownerId = tenantApp.tenantId
    }
    write audit log: actor=system, reason="migration from TenantAppModel"
}
```

### 12.3 上線步驟

1. 部署本服務,建立 Mongo collection + 索引
2. 配置 `DownstreamRouteRegistry`(各下游服務接入)
3. 平臺管理員透過 `/admin/integrations/apps` 建立 `aipower` 應用定義
4. 跑遷移指令碼匯入存量 `TenantAppModel` → `TenantIntegration` + `TenantMapping`
5. 切流量:AIPower 改用 `/openapi/v1/*`,各 Aile 子服務事件傳送點改用本服務 EventPublishClient
6. 觀察 EventLog + 閘道器訪問日誌一週,確認無迴歸
7. 下線 `/tenantapp/v1/*` 介面與 `TenantAppModel`

---

## 十三、階段一交付清單

| 類別 | 交付物 |
| --- | --- |
| 新建服務 | aile-service-platform-integration(完整 DDD 分層) |
| 新建 api 模組 | aile-platform-integration-api(模型+列舉+DTO+EventPublishClient+HmacSignatureService) |
| 新建 Mongo 集合 | `aile.platform.integration.app` / `aile.platform.tenant.integration` / `aile.platform.tenant.integration.audit` / `aile.platform.tenant.mapping` / `aile.platform.event.log` |
| 核心介面 | /install /update /uninstall /rotate-secret(控制面)
/openapi/v1/*(閘道器)
/admin/integrations/*(管理面) |
| 統一簽名 | HmacSignatureService 替換三套 SignatureUtils |
| 事件鏈路 | EventPublishAppService + Pub/Sub publisher + EventLog |
| 遷移指令碼 | TenantAppModel → TenantIntegration / TenantMapping |
| 下線項 | aile-service-job 中的 TenantAppModel、/tenantapp/v1/*、WebhookEventServiceImpl |
| 釋出範圍 | tenant.* / user.* / service_number.* / contact.* / visitor.* / group.* / addressbook.* / notice.*(本期 notice 子事件含 `delivered` / `failed` / `read` / `clicked` / `bounced` / `complained` / `task_completed` / `converted`,詳 §10.2) |
| 不在本期 | scope 鑑權、nonce 防重放、KMS 加密、AIPower 反向能力呼叫、PENDING_USER_CONFIRM 反向回撥、域名白名單、灰度相容期 |

---

## 十四、修改歷程

| 版本 | 日期 | 作者 | 修訂摘要 |
| --- | --- | --- | --- |
| v0.1 | — | chunlei zhu | 初版(已歸檔,不再維護) |
| v0.2 | 2026-05-20 | chunlei zhu | 系統性重寫:補齊安裝協議、閘道器層、事件三段鏈路;明確服務邊界為 Open Integration Layer(零業務領域邏輯);抽象外部租戶命名為 externalTenantId/externalSpaceId;鎖定本期不實現項 |
| v1.0 | 2026-05-21 | chunlei zhu | 定稿為開發實施版本:移除對其他設計文件的外部引用,將「與上位方案差異」改寫為本期最終設計決策,文件自包含,作為開發團隊實施指導依據 |
| v1.1 | 2026-05-21 | chunlei zhu | 引入服務號(serviceNumber)作為橫切上下文:§10.1 EventEnvelope 新增 scope 子結構(serviceNumberId);§10.2 事件清單按租戶級/服務號級分類並新增 contact.entered 等進線行為事件;§9.2 路由表新增 /openapi/v1/service-numbers/ 頂層入口與服務號巢狀資源路徑;§6.3 補充 contact ↔ service_number N:M 關係約定;§9.3 增加 SERVICE_NUMBER_FORBIDDEN 鑑權規則;明確 scope 與 serviceNumberId 均不參與簽名 |
| v1.2 | 2026-05-21 | chunlei zhu | 事件清單治理:統一服務號資源域命名為 `service_number`(ServiceNumber),取代原 `service_account`;§10.2 表格新增「中文說明」列,補齊每個資源域的業務含義;§6.3 / §9.3 / §十三 同步替換 service_account 表述;`service_number.*` 事件按服務號自身生命週期調整為 `created` / `updated` / `deleted`(原 `bound` / `unbound` 不屬於服務號自身動作,移除);contact 關注類事件 `service_number_linked` / `unlinked` 重新命名為 `service_number_followed` / `unfollowed`(語義為客戶是否關注該服務號),相應分類口徑同步調為「進線 / 關注行為」 |
| **v1.3** | **2026-05-22** | **chunlei zhu** | **推播場景閉環擴充套件**(為承接 AiReach 等推播類生態應用):§10.2 `notice.*` 資源域追加 5 項擴充套件事件 — `notice.clicked`(連結點選) / `notice.bounced`(投遞失敗子類:號碼無效 / 拒收 / 通道故障) / `notice.complained`(使用者標記騷擾) / `notice.task_completed`(整批傳送任務終態,可替代生態應用的輪詢) / `notice.converted`(Aile 側歸因轉化);§13 釋出範圍同步更新。本服務自身行為不變(仍按 EventEnvelope 封裝 → Pub/Sub → 獨立 webhook 服務投遞);由 aile-service-message 在對應業務時機發起 EventPublishClient 呼叫即可 |
| **v1.4** | **2026-05-22** | **chunlei zhu** | **`NoticeContent` 訊息契約 + `notice.clicked` 觸發規則固化**(§10.2 補充段落):1) 明確 Aile 不維護預審模板,應用按 `NoticeContent`(title / image / describe + buttons[],四類 actionType)自行組合傳送;2) **postback button payload 契約**:`appId`(必填,路由接收方) + `clickId`(必填,業務語義) + `extra`(可選);3) **`notice.clicked` payload 必含 `postbackPayload`**(原樣透傳傳送方按鈕 payload)**與可選 `inbound` 子結構**(triggered / conversationId / reason);4) **點選 ↔ 進線聯動由 Aile 編排**(aile-service-message 攔截點選 + aile-service-room 提供進線規則 → 填充 `inbound` 後發事件),生態應用收一條 event 即同時獲知點選 + 進線;5) **本服務僅在事件封裝環節保證上述結構透傳**,NoticeContent / 點選捕獲路由 / 進線規則引擎實現在 aile-service-message + aile-service-room |
| **v1.5** | **2026-05-26** | **chunlei zhu** | **§9.2 路由清單具體化 + 群發主鏈替代 notices 寫入 + EntrySource owner 整合規範**:1) §9.2 由萬用字元字首表(如 `/openapi/v1/service-numbers/&#123;snId&#125;/contacts/**`)重寫為具體 `method + path` 白名單清單,拆分為 §9.2.1 租戶級與 §9.2.2 服務號級兩張表,每條 API 必須顯式註冊,未登記一律 404;2) **群發主鏈固化為 `/openapi/v1/service-numbers/&#123;snId&#125;/broadcasts`**(A1 建立 / A2 查詢 / A3 取消),取代原「透過 `/service-numbers/&#123;snId&#125;/notices` 寫入」的群發入口,服務號級 `/notices/&#123;noticeId&#125;` 僅保留只讀語義,沿用 Aile 現行服務號群發流水線;3) 新增**聯絡人標籤整合介面**(`GET /contacts/labels` / `POST /contacts/labels:add` / `POST /contacts/labels:remove` / `GET /contacts/&#123;contactId&#125;/labels`)對齊 AiReach D 類用例;4) 新增**進線原因 OpenAPI**(`/openapi/v1/entry-sources` CRUD + 狀態切換)允許整合方租戶級自助登記 EntrySource;新增 §9.4「EntrySource 整合規範」明確 owner.integrationAppId / tenantIntegrationId 由閘道器自動注入、不可篡改、解除安裝時級聯停用;**觸發迴流按 owner 精確路由**——僅建立該 EntrySource 的 integration 收到事件,與同租戶其他 integration 隔離;5) §10.2 事件清單新增 `session.*` 資源域(`session.created` / `closed` / `transferred`)承載 EntrySource 觸發迴流 |
