# 自定義授權系統接入指南

當前源碼支持兩種授權提供方：Licentivo 和 `custom_http`。商戶在 **系統設置 → 授權系統** 統一選擇并啟用其中一個，所有商品和 sandbox/live 環境使用同一提供方；自定義連接與套餐映射仍按商品和環境分別配置。此功能不包含自建授權系統；商戶繼續使用自己的授權服務和 SDK。

現有 Licentivo 帳號交換、九語言 SDK 和設備管理繼續使用原流程。自定義商品通過本文協議同步購買狀態，客戶在帳號中心讀取授權交付，不進入 Licentivo SDK 流程。

## 快速開始

1. 在自己的服務實現下面四個 HTTP 接口，配置每個連接獨立的 HMAC 簽名密鑰和標識。
2. 在“授權系統”選擇“自定義授權服務”，新增連接，填寫地址、環境、連接標識、key ID 和標準 Base64 密鑰，驗證后保存，點擊“啟用自定義授權服務”。
3. 創建商品和銷售套餐草稿。購買類型和價格在 Launch 管理；授權規則在自有系統管理。
4. 選擇商品及環境，選擇連接，填寫外部商品與套餐引用并保存。提供方由全站選擇決定，不能為其他商品同時啟用 Licentivo。
5. 在銷售套餐頁發布測試版，完成付款、交付、續費、升級、退款及故障恢復驗收，再獨立配置正式環境。

地址、環境、連接標識和網路訪問策略創建后不可更改；需要時創建新連接。簽名密鑰可輪換，留空保留原密鑰。存在另一授權系統的訂單時，不能直接切換全站提供方，需要先完成遷移。切換后原套餐轉為僅預覽，需要重新發布；保存的連接憑證和映射保留。已有訂單的商品不能直接切換連接，銷售中的映射也不能直接更換；新價格或映射使用套餐新版本。

通過 API 配置時，先調用 `PUT /api/v1/admin/licensing/provider`，請求體為 `{"provider":"custom_http"}` 或 `{"provider":"licentivo"}`，再保存商品映射。未啟用的提供方不能保存商品授權綁定或 Licentivo 套餐策略映射。

連接檢查確認協議、鑒權、環境及支持的購買類型，不會替商戶創建或確認外部套餐。正式收款前必須用真實外部套餐完成 sandbox 接入驗收。

## 3. 商戶接入流程

1. 在已有授權系統前實現本文四個接口；授權系統已有的許可證邏輯繼續保留。
2. 在 Launch 新建“自定義授權服務”連接，配置 HTTPS 地址、連接標識、環境及簽名密鑰。
3. Launch 調用能力檢查接口，確認協議版本、永久/限時購買支持和交付方式。
4. 給商品選擇該連接；將銷售套餐版本映射到外部 `product_ref` 和 `plan_ref`。外部編號允許使用商戶自己的字符串，不強制使用 Licentivo UUID。
5. 在 sandbox 中完成付款、續費、升級、退款和故障恢復驗收，然后單獨發布 live 配置。
6. Launch 驗證付款后，在同一資料庫事務內保存訂單、購買覆蓋和待發送任務。
7. 后臺把完整購買快照發送給自定義授權服務；該服務持久化接收記錄后再確認接收。
8. 授權服務處理目標狀態。Launch 查詢原事件回執，只有 `applied` 才確認本次同步完成。
9. Launch 為已登錄且擁有該購買的客戶讀取交付資訊，在客戶中心展示授權碼、激活入口或純文本說明。應用接入商戶自己的授權系統。
10. 續費、升級、退款、撤款、暫停和到期均產生新版本的完整快照。授權服務按最新目標狀態處理。

同一商品的 sandbox/live 分別綁定連接。已有購買后，更換提供方需要獨立遷移流程；舊訂單和舊任務不能跟隨后臺選擇被靜默送到新服務。

## 4. 自定義授權服務需要實現的 API

以下接口均由**商戶授權服務接收，Launch 服務端調用**。`base_url` 可以包含固定路徑前綴；表中路徑追加在該前綴之后。

| 方法與路徑 | 做什么 |
| --- | --- |
| `GET /launch/v1/capabilities` | 連接檢查、協議與能力聲明 |
| `POST /launch/v1/events` | 接收完整購買目標狀態；重試沿用原事件 |
| `GET /launch/v1/events/{event_id}` | 查詢該事件是否實際應用，恢復丟失的響應 |
| `GET /launch/v1/entitlements/{entitlement_ref}/delivery` | 讀取已有授權的交付資訊，不創建第二份授權 |

首版采用主動查詢回執，不新增外部回調修改訂單的入口。自定義服務不具有確認付款、修改價格或改寫購買期限的權限。

### 4.1 通用約定與鑒權

- JSON 使用 UTF-8；時間使用 UTC RFC 3339；`version` 使用正整數。
- 成功響應為 `{"data": {...}, "request_id": "..."}`；錯誤響應為 `{"error": {"code": "...", "message": "..."}, "request_id": "..."}`。
- 每個環境和連接使用獨立密鑰。密鑰為至少 32 個隨機位元組，在 Launch 中引用現有加密密鑰存儲，不放進前端配置、示例或應用安裝包。
- 每個請求攜帶 `X-Launch-Key-Id`、`X-Launch-Timestamp`、`X-Launch-Nonce`、`X-Launch-Signature`。
- 時間戳為十進制 Unix 秒。nonce 為至少 128 位隨機值的無填充 base64url 編碼，每次網路嘗試生成新值。

簽名輸入是下列七項用單個換行符連接的 UTF-8 位元組，末尾不添加換行：

```text
v1
<key_id>
<timestamp>
<nonce>
<UPPERCASE HTTP method>
<encoded request path and raw query, including the base_url path prefix>
<lowercase hexadecimal SHA-256 digest of the exact request body>
```

`X-Launch-Signature` 由 `v1=` 前綴與 `HMAC-SHA256` 結果的無填充 base64url 編碼串接而成。GET 請求本文為空。接收端先驗證簽章和時間窗口，再反序列化；使用固定時間比較，預設允許 ±300 秒偏差，並至少保留 nonce 600 秒以防重播。重試不可重複使用 nonce，但事件 ID 和業務請求本文必須保持不變。

簽名不替代 HTTPS。請求不攜帶客戶密碼、支付密鑰或 Licentivo 管理密鑰；預設僅發送匿名帳號引用，不發送郵箱。服務端 HTTP 客戶端需要超時、響應限長和禁止攜帶憑據跟隨重定向。配置自定義地址時校驗目標地址；訪問內網應是管理員明確配置的部署能力。

### 4.2 能力檢查

`GET /launch/v1/capabilities` 返回 HTTP 200，例如：

```json
{
  "data": {
    "protocol": "launch-external-licensing-v1",
    "connection_ref": "merchant-01-sandbox",
    "environment": "sandbox",
    "purchase_types": ["perpetual", "fixed_term"],
    "delivery_types": ["license_key", "activation_url", "instructions"],
    "devices": false,
    "identity_exchange": false
  },
  "request_id": "check-001"
}
```

`protocol`、`connection_ref`、`environment`、`purchase_types`、`delivery_types` 必填。后兩項必須為非空數組，未支持的購買類型不能發布銷售。首版 `devices` 和 `identity_exchange` 必須為 `false`；只有擴展契約和實現完成后才允許聲明支持。

### 4.3 購買快照

`POST /launch/v1/events` 的請求示例：

```json
{
  "schema": "launch-external-licensing-v1",
  "event_id": "7bf84b91-2749-4b01-a6a7-bd527c488ee8",
  "connection_ref": "merchant-01-sandbox",
  "environment": "sandbox",
  "type": "entitlement.snapshot",
  "change_reason": "purchase",
  "account_ref": "customer_opaque_001",
  "entitlement_ref": "purchase_opaque_001",
  "launch_product_id": "c6dd463e-50f7-4b76-8239-0a8ec6fc304b",
  "launch_plan_version_id": "5459a073-9362-4b46-949d-9b0c5d84421a",
  "product_ref": "desktop-editor",
  "plan_ref": "professional-yearly-v1",
  "version": 1,
  "desired_state": "active",
  "valid_from": "2026-10-25T00:00:00Z",
  "valid_until": "2027-10-25T00:00:00Z"
}
```

上例所有字段均必填。`valid_until` 為 `null` 表示永久購買；其他值必須晚于 `valid_from`。

| 欄位 | 約束和含義 |
| --- | --- |
| `event_id` | Launch 生成的 UUID；同一事件重試不變 |
| `connection_ref` / `environment` | 必須與簽名密鑰所屬連接、環境一致 |
| `account_ref` | 穩定匿名帳號引用；接收方以連接、環境和該引用共同識別客戶 |
| `entitlement_ref` | 一次購買關系的穩定引用；全部撤銷后重新購買使用新引用 |
| `launch_product_id` / `launch_plan_version_id` | Launch 的商品與不可變套餐版本 UUID |
| `product_ref` / `plan_ref` | 接入方配置的外部引用；1–128 個 ASCII 字符，允許字母、數字、`_`、`-`、`.`；不自動創建外部套餐 |
| `version` | 同一連接、環境、購買引用下單調遞增的目標版本 |
| `desired_state` | `active`、`suspended`、`expired`、`revoked` |
| `change_reason` | `purchase`、`renewal`、`upgrade`、`refund`、`reversal`、`suspension`、`resumption`、`expiry`、`reconciliation` |

`account_ref`、`entitlement_ref` 使用 1–128 位字母、數字、下劃線或短橫線。`schema`、`type` 固定為示例中的值。接收方按 `desired_state` 和完整絕對期限應用結果，不根據原因自行加天數或撤銷整份授權。

特別是退款：退掉續費或升級訂單后仍可能保留有效基礎購買，此時快照仍可為 `active`，但期限縮短或套餐改變。只有賬本重算后不再有有效購買覆蓋，才發送對應終止狀態。

### 4.4 接收、回執與恢復

接收方必須在持久化事件 ID、原始請求體摘要及待執行任務之后，才返回 HTTP 202；已經同步完成的重復請求可以返回 HTTP 200。兩者都返回下述回執結構。

```json
{
  "data": {
    "event_id": "7bf84b91-2749-4b01-a6a7-bd527c488ee8",
    "connection_ref": "merchant-01-sandbox",
    "environment": "sandbox",
    "entitlement_ref": "purchase_opaque_001",
    "version": 1,
    "payload_sha256": "<64-character lowercase hexadecimal SHA-256 digest of the exact event body>",
    "status": "pending",
    "error_code": null
  },
  "request_id": "receipt-001"
}
```

摘要占位符需要替換為真實摘要。回執中上述字段均必填；`error_code` 無錯誤時為 `null`。查詢 `GET /launch/v1/events/{event_id}` 返回相同結構，HTTP 200，狀態可為：

| `status` | 含義 | Launch 的處理 |
| --- | --- | --- |
| `pending` | 已持久化，尚未完成實際授權變更 | 延后查詢，不能標記同步完成 |
| `applied` | 授權服務確認目標狀態已實際應用 | 核對全部關聯字段和摘要后確認同步完成 |
| `rejected` | 確定無法應用，例如外部套餐不存在 | 停止自動重試，顯示明確錯誤 |
| `manual_review` | 結果不確定或狀態沖突，需要人工核對 | 保留原任務和事件，等待可審計處理 |

交付狀態獨立于同步回執；`applied` 不能直接當成客戶應用的運行許可。

可靠性要求：

- 至少一次投遞，接收方必須保證業務冪等；不能承諾網路層“只調用一次”。
- 同一事件 ID、同一原始請求體返回已有結果；同一 ID、不同請求體返回 HTTP 409 `IDEMPOTENCY_CONFLICT`。
- 舊版本不得覆蓋新版本。同一購買版本的不同事件或不同目標不能靜默接受，應返回 HTTP 409 `STATE_VERSION_CONFLICT` 供核對。
- 丟失 POST 響應或進程重啟后，Launch 先 GET 原事件；明確返回 `EVENT_NOT_FOUND` 后才重發原 ID、原請求體。
- 遠端實際發放結果不明確時，接入方也應使用自己的持久化冪等引用核對，不能重發一次就再生成一份授權。
- HTTP 429 遵循 `Retry-After`；超時和 5xx 退避恢復；超過上限進入人工核對。鑒權、配置和業務拒絕不得被當成同步成功。

建議錯誤碼：`SIGNATURE_INVALID`（401）、`SIGNATURE_EXPIRED`（401）、`NONCE_REPLAYED`（409）、`SCOPE_MISMATCH`（403）、`EVENT_NOT_FOUND`（404）、`IDEMPOTENCY_CONFLICT`（409）、`STATE_VERSION_CONFLICT`（409）、`PLAN_UNKNOWN`（422）、`PURCHASE_TYPE_UNSUPPORTED`（422）、`RATE_LIMITED`（429）、`SERVICE_UNAVAILABLE`（503）。錯誤消息不得包含憑據。

### 4.5 授權交付

`GET /launch/v1/entitlements/{entitlement_ref}/delivery` 只能讀取該連接及環境中的已有交付結果，不得把查詢當作新發放命令。HTTP 200 示例：

```json
{
  "data": {
    "connection_ref": "merchant-01-sandbox",
    "environment": "sandbox",
    "entitlement_ref": "purchase_opaque_001",
    "version": 1,
    "status": "ready",
    "delivery": {
      "type": "license_key",
      "value": "EXAMPLE-ONLY-NOT-A-REAL-LICENSE",
      "expires_at": null
    }
  },
  "request_id": "delivery-001"
}
```

`status` 為 `pending`、`ready` 或 `unavailable`；非 ready 時 `delivery` 為 `null`。ready 時 `type` 必須為已聲明的 `license_key`、`activation_url` 或 `instructions`，`value` 最多 4096 個 UTF-8 位元組，`expires_at` 為 UTC 時間或 `null`。所有響應關聯字段必須與 Launch 當前購買版本一致，舊版本交付不能當作當前結果。

授權碼屬于敏感資料；交付響應使用 `Cache-Control: no-store`，不記錄正文，需要持久化時使用加密存儲。激活 URL 僅接受連接配置允許的 HTTPS 來源；instructions 按純文本展示，不執行 HTML 或腳本。已撤銷、全額退款或無有效購買的帳號不能繼續通過 Launch 取得交付資訊；已交付憑據的實際失效由外部系統執行。

## Launch 管理與客戶 API

這些接口位于商戶部署的 Launch origin 下，不使用外部服務的 HMAC 密鑰鑒權。管理員使用現有網站會話，寫請求必須帶 `X-CSRF-Token`。客戶只能讀取自己的購買。

| 方法與路徑 | 做什么 |
| --- | --- |
| `GET /api/v1/admin/licensing/connections` | 返回 `connections`、`bindings`、`mappings` 和 `default_provider`；連接列表只包含自定義連接，Licentivo 沿用原管理接口 |
| `POST /api/v1/admin/licensing/connections` | 驗證并新增自定義連接 |
| `PUT /api/v1/admin/licensing/connections/{id}` | 編輯名稱、允許的激活來源及簽名配置；歷史接收地址不變 |
| `POST /api/v1/admin/licensing/connections/{id}/check` | 重新檢查；HTTP 200 中的 `last_check_error` 非 null 表示檢查失敗 |
| `PUT /api/v1/admin/products/{id}/licensing` | 為商品所選環境配置提供方、連接與外部套餐映射 |
| `GET /api/v1/me/products/{id}/delivery?environment=sandbox` | 當前客戶讀取當前有效、自定義購買的授權交付；Cookie 或 desktop Bearer 鑒權 |
| `GET /api/v1/licensing/documentation` | 此文件，無需登錄 |
| `GET /api/v1/licensing/protocol` | 自定義服務需要實現的 OpenAPI，無需登錄 |

新增自定義連接的請求體：

```json
{
  "provider": "custom_http",
  "name": "My licensing service",
  "base_url": "https://licenses.example.com",
  "environment": "sandbox",
  "connection_ref": "merchant-01-sandbox",
  "key_id": "key-1",
  "signing_key": "<standard Base64 encoding of 32 to 128 random bytes>",
  "allow_private_network": false,
  "allow_local_http": false,
  "activation_origins": ["https://activate.example.com"]
}
```

響應 `data` 是脫敏后的連接對象，不返回簽名密鑰或 secret ID。allow_local_http 僅允許 sandbox 的 localhost/回環地址；私網 HTTPS 需要管理員顯式允許。密鑰留空僅限編輯已有連接。Licentivo 仍通過 `POST /api/v1/admin/connections/licentivo` 設置。

配置商品的請求體：

```json
{
  "provider": "custom_http",
  "environment": "sandbox",
  "connection_id": "<id returned when creating the connection>",
  "mappings": [
    {
      "version_id": "<Launch plan version UUID>",
      "product_ref": "desktop-editor",
      "plan_ref": "professional-yearly-v1"
    }
  ]
}
```

選擇 Licentivo 時發送 `{"provider":"licentivo","environment":"sandbox","mappings":[]}`，其產品/策略映射仍使用原接口。首次切換使已有發布變為預覽，需要按新提供方重新檢查并發布。mappings 可僅包含要新增或保留的版本；省略不會刪除舊映射。

客戶交付為 HTTP 200 的標準 data envelope；無購買返回 404，Licentivo 商品返回 409 `LICENSING_DELIVERY_UNSUPPORTED`，未同步、暫停、到期或全額退款返回 409 `ENTITLEMENT_NOT_READY`，外部暫不可用或回執不匹配返回 503。客戶中心的“查看授權交付”會重新校驗歸屬和狀態，不把憑據寫入 URL 或日志。

自定義商品的 Licentivo identity、device、integration-check 接口返回 409 `CUSTOM_LICENSING_SDK_REQUIRED`。舊 `/credential` 繼續返回 410。應用應使用商戶自己的 SDK，而不是把 Launch 的 `ready` 狀態當作運行授權。

## 示例與驗收

[Python 薄適配器示例](/zh-hant/launch-documents/custom-licensing-example/) 提供簽名、nonce、事件去重和回執持久化的接收框架；實際授權處理需要接入商戶系統。

自定義協議的 [OpenAPI](/zh-hant/assets/launch/external-licensing-openapi.json) 與 Launch 自身的 [OpenAPI](/zh-hant/assets/launch/openapi.json) 分別描述兩個方向。接收方無需實現 Licentivo 的產品目錄、策略目錄或身份信任接口。

本地驗證使用隔離 MySQL、真實 HTTP 適配器和模擬授權/支付服務，覆蓋響應丟失、重啟、續費、升級、退款、帳號停用、回執不匹配及交付隔離。生產驗收還需使用商戶自己的授權服務和應用。未聲明的設備、共享帳號登錄及離線能力不由 Launch 補齊。
