mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
feat(i7): async webhook delivery queue, OPS RBAC, UI role routing; docs and runbook
- Architect: I7_DESIGN.md, I7_IMPLEMENTATION_REVIEW.md; parallel index + track B - Backend: @EnableMethodSecurity; OPS login; CallbackInbox PreAuthorize; IntegrationCatalog triple role - Webhook: V2 webhook_platform_delivery; planner + scheduler + single-shot forwarder; tests - Frontend: Pinia hasAnyRole; MainLayout/HomeView/router for OPS vs dev - Runbook §10.5 delivery config Made-with: Cursor
This commit is contained in:
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
> **仓库**:`craftlabs-authorization-sdk`(分支 `develop`)。
|
> **仓库**:`craftlabs-authorization-sdk`(分支 `develop`)。
|
||||||
> **角色**:解决方案架构设计稿;**不**在本任务中落地代码。
|
> **角色**:解决方案架构设计稿;**不**在本任务中落地代码。
|
||||||
> **实现锚点**(与现有模式一致):`delivery-platform-api` 使用 Flyway `V5__…` 起(当前末版为 `V4__delivery_batch_and_license_sn.sql`)、`AuditService` + `AuditEntityTypes` / `AuditActions`、`ApiExceptionHandler`、公开业务 API 经 `SecurityConfig` + `JwtAuthenticationFilter`(`Authorization: Bearer`);OpenAPI SSOT 为 [`contracts/openapi/delivery-platform-api.json`](../../../contracts/openapi/delivery-platform-api.json) 与 `OpenApiContractSnapshotTest`。
|
> **实现锚点**(与现有模式一致):`delivery-platform-api` 使用 Flyway `V5__…` 起(当前末版为 `V4__delivery_batch_and_license_sn.sql`)、`AuditService` + `AuditEntityTypes` / `AuditActions`、`ApiExceptionHandler`、公开业务 API 经 `SecurityConfig` + `JwtAuthenticationFilter`(`Authorization: Bearer`);OpenAPI SSOT 为 `[contracts/openapi/delivery-platform-api.json](../../../contracts/openapi/delivery-platform-api.json)` 与 `OpenApiContractSnapshotTest`。
|
||||||
> **Webhook 工程名**:本工作区已实现为 `license-webhook-ingress`([`services/README.md`](../../../services/README.md):`webhook_callback_receipt`、`Idempotency-Key`)。
|
> **Webhook 工程名**:本工作区已实现为 `license-webhook-ingress`(`[services/README.md](../../../services/README.md)`:`webhook_callback_receipt`、`Idempotency-Key`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 上游文档走查(按路径引用,不全文摘录)
|
## 0. 上游文档走查(按路径引用,不全文摘录)
|
||||||
|
|
||||||
|
|
||||||
| 文档 | 与本设计的关系(摘要) |
|
| 文档 | 与本设计的关系(摘要) |
|
||||||
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [docs/engineering/PARALLEL_ITERATION_INDEX.md](../PARALLEL_ITERATION_INDEX.md) | 定义三轨并行;**I5** 为 **Callback + Schema** 硬耦合周;**I6** 为 **UAT** 与 SDK 冻结;同步点含 **I5 起**:Callback payload / inbox DTO、**Idempotency-Key**、Webhook→平台投递方式;**I6**:UAT 场景、`VITE_API_BASE`、两枚 Fat JAR 与 SDK 版本。 |
|
| [docs/engineering/PARALLEL_ITERATION_INDEX.md](../PARALLEL_ITERATION_INDEX.md) | 定义三轨并行;**I5** 为 **Callback + Schema** 硬耦合周;**I6** 为 **UAT** 与 SDK 冻结;同步点含 **I5 起**:Callback payload / inbox DTO、**Idempotency-Key**、Webhook→平台投递方式;**I6**:UAT 场景、`VITE_API_BASE`、两枚 Fat JAR 与 SDK 版本。 |
|
||||||
@@ -18,25 +19,29 @@
|
|||||||
| [docs/chuangfei-platform-product-modules.md](../../chuangfei-platform-product-modules.md) §6–7 | **M5 P0**:收件箱列表/详情/处理状态/关联失败兜底/事件类型字典(M5-F01~F05 等);**M6 P0**:产品线(M6-F01)、环境维度与 `bitanswer.url`(M6-F02);P1 如比特 ID 映射、JSON 模板、发布记录等 **I5 MVP 可裁减**。 |
|
| [docs/chuangfei-platform-product-modules.md](../../chuangfei-platform-product-modules.md) §6–7 | **M5 P0**:收件箱列表/详情/处理状态/关联失败兜底/事件类型字典(M5-F01~F05 等);**M6 P0**:产品线(M6-F01)、环境维度与 `bitanswer.url`(M6-F02);P1 如比特 ID 映射、JSON 模板、发布记录等 **I5 MVP 可裁减**。 |
|
||||||
| [docs/chuangfei-platform-bpm-and-roadmap.md](../../chuangfei-platform-bpm-and-roadmap.md) | **BP-06**:Webhook 验签 → 落库 → 解析关联 → Ops 处置;重复投递幂等。**BP-10**:M6 与 Schema、客户端配置发布链路;**依赖**本仓 `schemas/craftlabs-auth-config.schema.json`。 |
|
| [docs/chuangfei-platform-bpm-and-roadmap.md](../../chuangfei-platform-bpm-and-roadmap.md) | **BP-06**:Webhook 验签 → 落库 → 解析关联 → Ops 处置;重复投递幂等。**BP-10**:M6 与 Schema、客户端配置发布链路;**依赖**本仓 `schemas/craftlabs-auth-config.schema.json`。 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part A — I5:本单体工作区内的 MVP 切片
|
## Part A — I5:本单体工作区内的 MVP 切片
|
||||||
|
|
||||||
### A.1 问题与目标结果
|
### A.1 问题与目标结果
|
||||||
|
|
||||||
|
|
||||||
| 维度 | 说明 |
|
| 维度 | 说明 |
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **业务问题** | 比特规则 **HTTPS Callback** 需 **不断链、可审计、可运营处置**;平台侧需统一收件箱,避免只在 Webhook 边缘落库不可见。 |
|
| **业务问题** | 比特规则 **HTTPS Callback** 需 **不断链、可审计、可运营处置**;平台侧需统一收件箱,避免只在 Webhook 边缘落库不可见。 |
|
||||||
| **I5 目标结果(E2E)** | **模拟或真实 Callback** 经 `license-webhook-ingress` →(持久化收据后)**投递** → `delivery-platform-api` **Inbox 表出现一行**;运营账号用 JWT 在 UI **列表可见、详情可打开**。 |
|
| **I5 目标结果(E2E)** | **模拟或真实 Callback** 经 `license-webhook-ingress` →(持久化收据后)**投递** → `delivery-platform-api` **Inbox 表出现一行**;运营账号用 JWT 在 UI **列表可见、详情可打开**。 |
|
||||||
| **幂等** | 与轨道 A 一致:**`Idempotency-Key`**(HTTP 头)+ 比特稳定 **`message_id`**(或等价字段);DB **唯一约束 `(source_system, external_message_id)`**;重复请求 **不重复插入**、返回与首次一致的接受语义(HTTP 200 + 相同 `inboxId` 或约定 DTO)。 |
|
| **幂等** | 与轨道 A 一致:`**Idempotency-Key`**(HTTP 头)+ 比特稳定 `**message_id**`(或等价字段);DB **唯一约束 `(source_system, external_message_id)`**;重复请求 **不重复插入**、返回与首次一致的接受语义(HTTP 200 + 相同 `inboxId` 或约定 DTO)。 |
|
||||||
| **schemaVersion** | 事件体或头携带 **`schemaVersion`**(与轨道 A 的 `X-Event-Schema-Version` 二选一或并存,**须写 ADR 定一种主口径**);平台拒绝无法识别的 major 版本时返回 **4xx** 并记录可观测字段,避免静默损坏。 |
|
| **schemaVersion** | 事件体或头携带 `**schemaVersion`**(与轨道 A 的 `X-Event-Schema-Version` 二选一或并存,**须写 ADR 定一种主口径**);平台拒绝无法识别的 major 版本时返回 **4xx** 并记录可观测字段,避免静默损坏。 |
|
||||||
|
|
||||||
|
|
||||||
### A.2 数据模型(`delivery-platform-api`,PostgreSQL + Flyway)
|
### A.2 数据模型(`delivery-platform-api`,PostgreSQL + Flyway)
|
||||||
|
|
||||||
命名与现有表一致采用 **`platform_*` 前缀**(见 `V1`~`V4` 迁移)。
|
命名与现有表一致采用 `**platform_*` 前缀**(见 `V1`~`V4` 迁移)。
|
||||||
|
|
||||||
#### A.2.1 `platform_callback_inbox`(M5 Inbox P0)
|
#### A.2.1 `platform_callback_inbox`(M5 Inbox P0)
|
||||||
|
|
||||||
|
|
||||||
| 列(示例) | 类型/说明 |
|
| 列(示例) | 类型/说明 |
|
||||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
| `id` | UUID / BIGSERIAL PK |
|
| `id` | UUID / BIGSERIAL PK |
|
||||||
@@ -45,7 +50,7 @@
|
|||||||
| **唯一约束** | `UNIQUE (source_system, external_message_id)` |
|
| **唯一约束** | `UNIQUE (source_system, external_message_id)` |
|
||||||
| `schema_version` | VARCHAR,与 payload 解析版本对齐 |
|
| `schema_version` | VARCHAR,与 payload 解析版本对齐 |
|
||||||
| `event_type` | VARCHAR,与 M5-F05 字典一致(如 `sn:pre_activate`) |
|
| `event_type` | VARCHAR,与 M5-F05 字典一致(如 `sn:pre_activate`) |
|
||||||
| `status` | ENUM/VARCHAR:**`PENDING` / `PROCESSED` / `FAILED` / `IGNORED`**(对应产品「待处理、已处理、失败、忽略」) |
|
| `status` | ENUM/VARCHAR:`**PENDING` / `PROCESSED` / `FAILED` / `IGNORED`**(对应产品「待处理、已处理、失败、忽略」) |
|
||||||
| `raw_payload` | **JSONB**(或 TEXT + 大小上限);UI **脱敏展示** |
|
| `raw_payload` | **JSONB**(或 TEXT + 大小上限);UI **脱敏展示** |
|
||||||
| `idempotency_key` | VARCHAR NULL,审计与排障 |
|
| `idempotency_key` | VARCHAR NULL,审计与排障 |
|
||||||
| **关联(均可 NULL,支撑 M5-F04)** | `license_sn_id` → `platform_license_sn`;`contract_id` / `project_id`(若已有表);解析字段如 `sn_code`、`mid` 等冗余列便于列表筛选 |
|
| **关联(均可 NULL,支撑 M5-F04)** | `license_sn_id` → `platform_license_sn`;`contract_id` / `project_id`(若已有表);解析字段如 `sn_code`、`mid` 等冗余列便于列表筛选 |
|
||||||
@@ -53,51 +58,60 @@
|
|||||||
| `received_at` | 平台收件时间 |
|
| `received_at` | 平台收件时间 |
|
||||||
| `processed_at` / `processed_by_user_id` | 运营处置 |
|
| `processed_at` / `processed_by_user_id` | 运营处置 |
|
||||||
| `failure_reason` / `operator_note` | 文本,P1 可扩展「失败原因分类」字典 |
|
| `failure_reason` / `operator_note` | 文本,P1 可扩展「失败原因分类」字典 |
|
||||||
| 标准审计 | 与现有实体一致可补充 `created_at`/`updated_at`;关键状态迁移建议走 **`AuditService`**(扩展 `AuditEntityTypes` / `AuditActions`) |
|
| 标准审计 | 与现有实体一致可补充 `created_at`/`updated_at`;关键状态迁移建议走 `**AuditService`**(扩展 `AuditEntityTypes` / `AuditActions`) |
|
||||||
|
|
||||||
|
|
||||||
#### A.2.2 M6 最小只读支撑表
|
#### A.2.2 M6 最小只读支撑表
|
||||||
|
|
||||||
仅包含 **I5 UI 与 Inbox 筛选** 所需字段;**不做** M6-F03~F06 全量。
|
仅包含 **I5 UI 与 Inbox 筛选** 所需字段;**不做** M6-F03~F06 全量。
|
||||||
|
|
||||||
| 表 | P0 字段(示例) |
|
|
||||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
| 表 | P0 字段(示例) |
|
||||||
| **`platform_product_line`** | `id`、`code`(唯一)、`name`、`description` NULL、启用标志 |
|
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **`platform_integration_environment`** | `id`、`code`(唯一)、`name`、`bitanswer_base_url`(对应 M6-F02)、`kind`(DEV/TEST/STAGING/PROD 等枚举)、可选 `product_line_id` 或后续多对多(MVP 可 **单列 FK** 简化) |
|
| `**platform_product_line`** | `id`、`code`(唯一)、`name`、`description` NULL、启用标志 |
|
||||||
|
| `**platform_integration_environment**` | `id`、`code`(唯一)、`name`、`bitanswer_base_url`(对应 M6-F02)、`kind`(DEV/TEST/STAGING/PROD 等枚举)、可选 `product_line_id` 或后续多对多(MVP 可 **单列 FK** 简化) |
|
||||||
|
|
||||||
|
|
||||||
**MVP 裁减**:比特产品/模版/业务 ID 映射(M6-F03)、特征映射(M6-F04)、JSON 模板与发布记录(M6-F05/F06)**推迟**至 V1.1 或 Mid,除非比特联调硬依赖。
|
**MVP 裁减**:比特产品/模版/业务 ID 映射(M6-F03)、特征映射(M6-F04)、JSON 模板与发布记录(M6-F05/F06)**推迟**至 V1.1 或 Mid,除非比特联调硬依赖。
|
||||||
|
|
||||||
### A.3 公开 REST API(JWT,`/api/v1`)
|
### A.3 公开 REST API(JWT,`/api/v1`)
|
||||||
|
|
||||||
与现有 Controller 风格一致;**RBAC**:与当前演示一致 **`SYS_ADMIN` / `DEVELOPER`** 均可访问 MVP 接口(与 [`AuthController`](../../../services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java) 对齐),**I7+** 再收紧为 Ops 专用权限码。
|
与现有 Controller 风格一致;**RBAC**:与当前演示一致 `**SYS_ADMIN` / `DEVELOPER`** 均可访问 MVP 接口(与 `[AuthController](../../../services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java)` 对齐),**I7+** 再收紧为 Ops 专用权限码。
|
||||||
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| ------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `GET` | `/api/v1/callback-inbox` | 分页;查询建议:`status`、`eventType`、`snCode`、`projectId`、`productLineId`、`environmentId`、`from`/`to`(`receivedAt`)、`page`、`size` |
|
| `GET` | `/api/v1/callback-inbox` | 分页;查询建议:`status`、`eventType`、`snCode`、`projectId`、`productLineId`、`environmentId`、`from`/`to`(`receivedAt`)、`page`、`size` |
|
||||||
| `GET` | `/api/v1/callback-inbox/{id}` | 详情;含 `rawPayload`(或单独 `GET .../payload` 若需权限分级) |
|
| `GET` | `/api/v1/callback-inbox/{id}` | 详情;含 `rawPayload`(或单独 `GET .../payload` 若需权限分级) |
|
||||||
| `PATCH` | `/api/v1/callback-inbox/{id}/status` | 运营状态迁移:**`PENDING` → `PROCESSED` / `FAILED` / `IGNORED`**;非法迁移 **409** + 业务错误码(与合同/交付模式一致,经 **`ApiExceptionHandler`**) |
|
| `PATCH` | `/api/v1/callback-inbox/{id}/status` | 运营状态迁移:`**PENDING` → `PROCESSED` / `FAILED` / `IGNORED`**;非法迁移 **409** + 业务错误码(与合同/交付模式一致,经 `**ApiExceptionHandler`**) |
|
||||||
| `PATCH` | `/api/v1/callback-inbox/{id}/link`(可选) | 人工挂接:`licenseSnId` / `projectId` / `contractId` 等,支撑 M5-F04 |
|
| `PATCH` | `/api/v1/callback-inbox/{id}/link`(可选) | 人工挂接:`licenseSnId` / `projectId` / `contractId` 等,支撑 M5-F04 |
|
||||||
|
|
||||||
|
|
||||||
**可选**:`POST /api/v1/callback-inbox/simulate`(**仅非生产**,对应 M5-F10 P2 — **I5 若排期紧可不做**,改用 curl → Webhook → 平台链)。
|
**可选**:`POST /api/v1/callback-inbox/simulate`(**仅非生产**,对应 M5-F10 P2 — **I5 若排期紧可不做**,改用 curl → Webhook → 平台链)。
|
||||||
|
|
||||||
**M6 只读**
|
**M6 只读**
|
||||||
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| ----- | ---------------------------------------------------------------- | ------ |
|
| ----- | ---------------------------------------------------------------- | ------ |
|
||||||
| `GET` | `/api/v1/integration/environments` | 列表/分页 |
|
| `GET` | `/api/v1/integration/environments` | 列表/分页 |
|
||||||
| `GET` | `/api/v1/integration/product-lines` | 列表/分页 |
|
| `GET` | `/api/v1/integration/product-lines` | 列表/分页 |
|
||||||
| `GET` | `/api/v1/integration/environments/{id}`、`.../product-lines/{id}` | 详情(按需) |
|
| `GET` | `/api/v1/integration/environments/{id}`、`.../product-lines/{id}` | 详情(按需) |
|
||||||
|
|
||||||
|
|
||||||
写接口(维护环境/产品线)MVP 可 **仅种子数据 + Flyway** 或 **管理员 POST**(若 I5 周可交付则加 `POST/PUT`,否则 **推迟**)。
|
写接口(维护环境/产品线)MVP 可 **仅种子数据 + Flyway** 或 **管理员 POST**(若 I5 周可交付则加 `POST/PUT`,否则 **推迟**)。
|
||||||
|
|
||||||
### A.4 内部 API(平台服务间,`license-webhook-ingress` → `delivery-platform-api`)
|
### A.4 内部 API(平台服务间,`license-webhook-ingress` → `delivery-platform-api`)
|
||||||
|
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **路径** | `POST /internal/v1/callback-events` |
|
| **路径** | `POST /internal/v1/callback-events` |
|
||||||
| **认证(MVP 推荐)** | 共享密钥:**`X-Platform-Internal-Token`**(或 `Authorization: Bearer <internal>`),配置与 `PLATFORM_JWT_SECRET` 分离;**生产建议路线图**:mTLS 或双向签名(文档中注明 **I6 Runbook:轮换步骤**)。 |
|
| **认证(MVP 推荐)** | 共享密钥:`**X-Platform-Internal-Token`**(或 `Authorization: Bearer <internal>`),配置与 `PLATFORM_JWT_SECRET` 分离;**生产建议路线图**:mTLS 或双向签名(文档中注明 **I6 Runbook:轮换步骤**)。 |
|
||||||
| **幂等** | 请求头 **`Idempotency-Key`** + 体 **`sourceSystem` + `externalMessageId`** 与 Inbox 唯一键一致;重复 POST → **200** 且 body 指向同一 `inboxId`。 |
|
| **幂等** | 请求头 `**Idempotency-Key`** + 体 `**sourceSystem` + `externalMessageId**` 与 Inbox 唯一键一致;重复 POST → **200** 且 body 指向同一 `inboxId`。 |
|
||||||
| **行为** | 校验 `schemaVersion` → 插入或跳过(幂等)→ 尝试解析并 **填充关联列**(失败则 `status=PENDING` 保留人工挂接)。 |
|
| **行为** | 校验 `schemaVersion` → 插入或跳过(幂等)→ 尝试解析并 **填充关联列**(失败则 `status=PENDING` 保留人工挂接)。 |
|
||||||
|
|
||||||
|
|
||||||
**请求 / 响应 JSON 示例(示意)**
|
**请求 / 响应 JSON 示例(示意)**
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -128,38 +142,44 @@ Content-Type: application/json
|
|||||||
|
|
||||||
### A.5 `license-webhook-ingress` 链路
|
### A.5 `license-webhook-ingress` 链路
|
||||||
|
|
||||||
|
|
||||||
| 步骤 | 说明 |
|
| 步骤 | 说明 |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | **验签 / token**(现有 `x-bitanswer-token` 与 `CRAFTLABS_WEBHOOK_EXPECTED_TOKEN`) |
|
| 1 | **验签 / token**(现有 `x-bitanswer-token` 与 `CRAFTLABS_WEBHOOK_EXPECTED_TOKEN`) |
|
||||||
| 2 | **持久化收据**(现有 **`webhook_callback_receipt`** + **`Idempotency-Key`** 幂等) |
|
| 2 | **持久化收据**(现有 `**webhook_callback_receipt`** + `**Idempotency-Key**` 幂等) |
|
||||||
| 3 | **HTTP 转发** 至平台 `POST /internal/v1/callback-events`:**带重试**(指数退避、最大次数、超时);贯通 **`traceparent` / `X-Request-Id`**(轨道 A §3) |
|
| 3 | **HTTP 转发** 至平台 `POST /internal/v1/callback-events`:**带重试**(指数退避、最大次数、超时);贯通 `**traceparent` / `X-Request-Id`**(轨道 A §3) |
|
||||||
| **对比特的 HTTP 响应** | 与轨道 A 一致:**2xx 须在收据已持久化(或可靠入队)之后**再返回。**MVP 推荐**:先落库收据即对比特 **2xx**,平台投递 **异步重试**;若 **同步** 转发,须 **短超时** 且平台幂等,避免比特侧超时重放放大。 |
|
| **对比特的 HTTP 响应** | 与轨道 A 一致:**2xx 须在收据已持久化(或可靠入队)之后**再返回。**MVP 推荐**:先落库收据即对比特 **2xx**,平台投递 **异步重试**;若 **同步** 转发,须 **短超时** 且平台幂等,避免比特侧超时重放放大。 |
|
||||||
| **平台非 2xx** | Webhook 侧重试;仍失败则记 **DLQ/失败计数**(日志 + DB 字段,**M5-F08 完整监控可推迟**);**不**因平台暂时不可用而对已持久化收据重复向比特报错(若已 2xx)。 |
|
| **平台非 2xx** | Webhook 侧重试;仍失败则记 **DLQ/失败计数**(日志 + DB 字段,**M5-F08 完整监控可推迟**);**不**因平台暂时不可用而对已持久化收据重复向比特报错(若已 2xx)。 |
|
||||||
|
|
||||||
|
|
||||||
### A.6 OpenAPI 与 springdoc
|
### A.6 OpenAPI 与 springdoc
|
||||||
|
|
||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **SSOT** | 更新 [`contracts/openapi/delivery-platform-api.json`](../../../contracts/openapi/delivery-platform-api.json):仅 **公开** `/api/v1/callback-inbox*`、`/api/v1/integration/*` 路径、`components/schemas`、错误码与 `security`(Bearer JWT)。 |
|
| **SSOT** | 更新 `[contracts/openapi/delivery-platform-api.json](../../../contracts/openapi/delivery-platform-api.json)`:仅 **公开** `/api/v1/callback-inbox*`、`/api/v1/integration/*` 路径、`components/schemas`、错误码与 `security`(Bearer JWT)。 |
|
||||||
| **内部路由** | **`/internal/**` 建议排除在默认 springdoc 分组之外**,或打上 tag **`internal`** 且 **生产禁用**该分组(与「对外契约」分离,避免集成方误用)。 |
|
| **内部路由** | `**/internal/`** 建议排除在默认 springdoc 分组之外**,或打上 tag `**internal`** 且 **生产禁用**该分组(与「对外契约」分离,避免集成方误用)。 |
|
||||||
| **校验** | `UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest`(见 [`contracts/README.md`](../../../contracts/README.md))。 |
|
| **校验** | `UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest`(见 `[contracts/README.md](../../../contracts/README.md)`)。 |
|
||||||
|
|
||||||
|
|
||||||
### A.7 前端(`web/delivery-platform-ui`)
|
### A.7 前端(`web/delivery-platform-ui`)
|
||||||
|
|
||||||
|
|
||||||
| 路由 | 页面职责 |
|
| 路由 | 页面职责 |
|
||||||
| ---------------------------- | ---------------------------------------------------- |
|
| ---------------------------- | ---------------------------------------------------- |
|
||||||
| `/callbacks` | Inbox 列表、`CallbackInboxTable`;跳转详情 |
|
| `/callbacks` | Inbox 列表、`CallbackInboxTable`;跳转详情 |
|
||||||
| `/callbacks/:id` | 详情 + **`CallbackPayloadViewer`(脱敏)**;状态 PATCH、可选人工挂接 |
|
| `/callbacks/:id` | 详情 + `**CallbackPayloadViewer`(脱敏)**;状态 PATCH、可选人工挂接 |
|
||||||
| `/integration/environments` | M6 环境只读表 |
|
| `/integration/environments` | M6 环境只读表 |
|
||||||
| `/integration/product-lines` | 产品线只读表 |
|
| `/integration/product-lines` | 产品线只读表 |
|
||||||
|
|
||||||
|
|
||||||
路由 meta:**权限码与 I1 壳一致**;菜单对 **SYS_ADMIN / DEVELOPER** 可见(MVP)。
|
路由 meta:**权限码与 I1 壳一致**;菜单对 **SYS_ADMIN / DEVELOPER** 可见(MVP)。
|
||||||
|
|
||||||
### A.8 SDK 轨道(本仓库,I5 硬交付清单)
|
### A.8 SDK 轨道(本仓库,I5 硬交付清单)
|
||||||
|
|
||||||
- **Schema**:`schemas/craftlabs-auth-config.schema.json` 等 — 若 BP-10 变更类型触及「平台导出 → Schema → 客户端」,按 [轨道 C §3](../tracks/03-client-sdk.md) bump 规则执行。
|
- **Schema**:`schemas/craftlabs-auth-config.schema.json` 等 — 若 BP-10 变更类型触及「平台导出 → Schema → 客户端」,按 [轨道 C §3](../tracks/03-client-sdk.md) bump 规则执行。
|
||||||
- **Java `AuthConfigs`**:与 Schema 同步(表见轨道 C BP-10)。
|
- **Java `AuthConfigs`**:与 Schema 同步(表见轨道 C BP-10)。
|
||||||
- **`examples/`**:与最新字段及环境变量说明一致;CI 与 Schema 校验对齐。
|
- `**examples/**`:与最新字段及环境变量说明一致;CI 与 Schema 校验对齐。
|
||||||
- **文档**:明确 **SDK 版本线 ≠ 平台 Fat JAR 版本**;引用 BPM **BP-10** 与产品 M6-F01/F02 口径。
|
- **文档**:明确 **SDK 版本线 ≠ 平台 Fat JAR 版本**;引用 BPM **BP-10** 与产品 M6-F01/F02 口径。
|
||||||
- **不做**:Native 与 Webhook 运行时耦合;平台不嵌入 JNI。
|
- **不做**:Native 与 Webhook 运行时耦合;平台不嵌入 JNI。
|
||||||
|
|
||||||
@@ -167,20 +187,22 @@ Content-Type: application/json
|
|||||||
|
|
||||||
## Part B — I6:规划与收口文档
|
## Part B — I6:规划与收口文档
|
||||||
|
|
||||||
| 主题 | 内容要点 | 执行文档 |
|
|
||||||
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
| 主题 | 内容要点 | 执行文档 |
|
||||||
| **UAT 门禁** | 跑通 **BP-01~06、11** 主链路(与 [轨道 B I6](../tracks/02-frontend-platform-ui.md) E2E 一致);**Callback** 场景含重复投递幂等、关联失败人工挂接。 | [I6_CLOSEOUT.md §2](./I6_CLOSEOUT.md) |
|
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| **冻结清单** | **SDK**:定版 tag、CHANGELOG、**BitAnswer 兼容矩阵**(轨道 C);**OpenAPI** 快照冻结;**两 JAR** 版本与镜像标签可追踪;前端 **`VITE_API_BASE`** 环境矩阵文档化。 | [I6_CLOSEOUT.md §3~§4](./I6_CLOSEOUT.md) |
|
| **UAT 门禁** | 跑通 **BP-01~06、11** 主链路(与 [轨道 B I6](../tracks/02-frontend-platform-ui.md) E2E 一致);**Callback** 场景含重复投递幂等、关联失败人工挂接。 | [I6_CLOSEOUT.md §2](./I6_CLOSEOUT.md) |
|
||||||
| **Runbook** | [`services/RUNBOOK.md`](../../../services/RUNBOOK.md):**内部 token 轮换**、Webhook→平台连通性检查、DB 迁移顺序(`flyway_platform_api` / `flyway_webhook`)。 | RUNBOOK **§10** |
|
| **冻结清单** | **SDK**:定版 tag、CHANGELOG、**BitAnswer 兼容矩阵**(轨道 C);**OpenAPI** 快照冻结;**两 JAR** 版本与镜像标签可追踪;前端 `**VITE_API_BASE`** 环境矩阵文档化。 | [I6_CLOSEOUT.md §3~§4](./I6_CLOSEOUT.md) |
|
||||||
|
| **Runbook** | `[services/RUNBOOK.md](../../../services/RUNBOOK.md)`:**内部 token 轮换**、Webhook→平台连通性检查、DB 迁移顺序(`flyway_platform_api` / `flyway_webhook`)。 | RUNBOOK **§10** |
|
||||||
| **安全加固** | **安全响应头**、Cookie/Session 策略(若 Mid 前仍为 JWT 则文档化 **仅 Bearer**)、依赖扫描与已知 CVE 处理;**禁止** I6 周排入大块新功能(仅缺陷与加固)。 | 平台 `SecurityConfig` + Runbook + [CI 依赖/CVE 扫描](../../../.github/workflows/ci-security.yml) |
|
| **安全加固** | **安全响应头**、Cookie/Session 策略(若 Mid 前仍为 JWT 则文档化 **仅 Bearer**)、依赖扫描与已知 CVE 处理;**禁止** I6 周排入大块新功能(仅缺陷与加固)。 | 平台 `SecurityConfig` + Runbook + [CI 依赖/CVE 扫描](../../../.github/workflows/ci-security.yml) |
|
||||||
| **实现审核** | I1~I6 对照设计与三轨道文档 | [I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md) |
|
| **实现审核** | I1~I6 对照设计与三轨道文档 | [I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md) |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part C — 实现顺序与 MVP 切割
|
## Part C — 实现顺序与 MVP 切割
|
||||||
|
|
||||||
1. **平台 DB(Flyway `V5+`)**:`platform_callback_inbox` + M6 两表 + 必要索引与外键;种子数据(环境/产品线)可选。
|
1. **平台 DB(Flyway `V5+`)**:`platform_callback_inbox` + M6 两表 + 必要索引与外键;种子数据(环境/产品线)可选。
|
||||||
2. **平台内部 API**:`POST /internal/v1/callback-events` + 幂等与 `schemaVersion` 校验 + **`AuditService` 钩子**(状态/关联变更)。
|
2. **平台内部 API**:`POST /internal/v1/callback-events` + 幂等与 `schemaVersion` 校验 + `**AuditService` 钩子**(状态/关联变更)。
|
||||||
3. **平台公开 API**:`GET/PATCH` callback-inbox、M6 只读 GET;**统一异常**经 `ApiExceptionHandler`。
|
3. **平台公开 API**:`GET/PATCH` callback-inbox、M6 只读 GET;**统一异常**经 `ApiExceptionHandler`。
|
||||||
4. **OpenAPI 快照** 与契约测试更新。
|
4. **OpenAPI 快照** 与契约测试更新。
|
||||||
5. **Webhook**:在收据落库后增加 **转发客户端**(重试、观测头);配置项:`PLATFORM_INTERNAL_BASE_URL`、`PLATFORM_INTERNAL_TOKEN`。
|
5. **Webhook**:在收据落库后增加 **转发客户端**(重试、观测头);配置项:`PLATFORM_INTERNAL_BASE_URL`、`PLATFORM_INTERNAL_TOKEN`。
|
||||||
@@ -190,6 +212,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
**MVP 明确推迟(若全量 M5/M6 过大)**
|
**MVP 明确推迟(若全量 M5/M6 过大)**
|
||||||
|
|
||||||
|
|
||||||
| 推迟项 | 说明 |
|
| 推迟项 | 说明 |
|
||||||
| ---------- | ------------------------------------------ |
|
| ---------- | ------------------------------------------ |
|
||||||
| M6-F03~F09 | 比特 ID 映射、特征映射、模板库、发布记录、影响分析 |
|
| M6-F03~F09 | 比特 ID 映射、特征映射、模板库、发布记录、影响分析 |
|
||||||
@@ -197,10 +220,12 @@ Content-Type: application/json
|
|||||||
| M5-F10 | 模拟投递 UI(可用 curl/Postman 代替) |
|
| M5-F10 | 模拟投递 UI(可用 curl/Postman 代替) |
|
||||||
| MQ 投递 | 保留 HTTP MVP;MQ + 消费者为 ADR 备选(轨道 A 已列 B 方案) |
|
| MQ 投递 | 保留 HTTP MVP;MQ + 消费者为 ADR 备选(轨道 A 已列 B 方案) |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Part D — 可追溯性(设计章节 → 产品功能点)
|
## Part D — 可追溯性(设计章节 → 产品功能点)
|
||||||
|
|
||||||
|
|
||||||
| 设计章节 | 产品模块 / 功能点(适用处) |
|
| 设计章节 | 产品模块 / 功能点(适用处) |
|
||||||
| ------------------------------- | ------------------------------ |
|
| ------------------------------- | ------------------------------ |
|
||||||
| A.1 幂等与 schemaVersion | M5 运营基础;BP-06 |
|
| A.1 幂等与 schemaVersion | M5 运营基础;BP-06 |
|
||||||
@@ -213,12 +238,16 @@ Content-Type: application/json
|
|||||||
| A.8 SDK / Schema | **BP-10**;**M6** 配置治理(文档与校验链) |
|
| A.8 SDK / Schema | **BP-10**;**M6** 配置治理(文档与校验链) |
|
||||||
| Part B I6 | BP-01~06、11 UAT;M11 安全与运维 |
|
| Part B I6 | BP-01~06、11 UAT;M11 安全与运维 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 修订记录
|
## 修订记录
|
||||||
|
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
| ---------- | ----------------------------------------------------------------- |
|
| ---------- | ----------------------------------------------------------------- |
|
||||||
| 2026-04-06 | 初版:I5/I6 架构设计,对齐并行索引、三轨道文档与产品 M5/M6 P0。 |
|
| 2026-04-06 | 初版:I5/I6 架构设计,对齐并行索引、三轨道文档与产品 M5/M6 P0。 |
|
||||||
| 2026-04-06 | Part B 关联 I6 收口文档(CLOSEOUT / IMPLEMENTATION_REVIEW)与 RUNBOOK §10。 |
|
| 2026-04-06 | Part B 关联 I6 收口文档(CLOSEOUT / IMPLEMENTATION_REVIEW)与 RUNBOOK §10。 |
|
||||||
| 2026-04-06 | 固化 Markdown 引用与强调;Part B 补充 CI 依赖/CVE 扫描入口。 |
|
| 2026-04-06 | 固化 Markdown 引用与强调;Part B 补充 CI 依赖/CVE 扫描入口。 |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,3 +88,4 @@
|
|||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-04-06 | 初版:I6 架构师审核,对照 I5_I6_DESIGN 与三轨道文档。 |
|
| 2026-04-06 | 初版:I6 架构师审核,对照 I5_I6_DESIGN 与三轨道文档。 |
|
||||||
|
| 2026-04-06 | I6 表:补充 CI 依赖/CVE 与 Dependabot。 |
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 迭代 I7 架构设计 — 可靠投递、运营权限、前端指令
|
||||||
|
|
||||||
|
> **仓库**:`craftlabs-authorization-sdk`(`develop`)。
|
||||||
|
> **前置**:[I6 收口](./I6_CLOSEOUT.md)、[I6 实现审核](./I6_IMPLEMENTATION_REVIEW.md) §3 跟踪项。
|
||||||
|
> **角色**:迭代功能架构说明;实现以本文件为审查基线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
|
||||||
|
| 主题 | I7 要达成 | 非目标(后置) |
|
||||||
|
| ---------------- | -------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||||
|
| **Webhook → 平台** | 对比特 **2xx 后立即返回**;平台投递 **异步**、可重试、**落库可观测**(状态/次数/最后错误) | MQ、跨地域多活 |
|
||||||
|
| **运营权限** | 新增 `**OPS`**;**Callback Inbox**(读/改/挂接)仅 `**OPS` + `SYS_ADMIN`**;`**DEVELOPER**` 保留其余业务与 **M6 只读** | 细粒度按钮与数据范围(I8+) |
|
||||||
|
| **前端** | 路由 `meta.roles` 与菜单 `**hasAnyRole`** 一致;登录页说明 `**ops/ops**` | Playwright 流水线(I7.1 可选) |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Webhook 侧:平台投递出站队列
|
||||||
|
|
||||||
|
### 2.1 行为
|
||||||
|
|
||||||
|
1. `**webhook_callback_receipt` 首次插入成功** 且配置了 `craftlabs.platform.internal.base-url` + token 时,写入 `**webhook_platform_delivery`**,`status=PENDING`。
|
||||||
|
2. **不**在 Callback HTTP 线程内同步 `RestClient` 调用平台(避免比特侧拖慢与线程占用)。
|
||||||
|
3. `**@Scheduled`** 周期拉取 `PENDING` / 可重试失败行,`POST /internal/v1/callback-events`;成功 → `SENT`;失败 → `attempts++`,指数退避 `**next_retry_at**`,超过 `**max-attempts**` → `DEAD`(人工/运维处理)。
|
||||||
|
4. **未配置 base-url** 时:不建队列行(与 I5「仅收据」行为一致)。
|
||||||
|
|
||||||
|
### 2.2 表(Flyway `V2__webhook_platform_delivery.sql`)
|
||||||
|
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
| ----------------------------------------- | ------------------------------------- |
|
||||||
|
| `receipt_id` | 关联收据 |
|
||||||
|
| `request_body` | 平台 API JSON 正文 |
|
||||||
|
| `trace_headers_json` | `traceparent` / `X-Request-Id` 等 JSON |
|
||||||
|
| `idempotency_key` | 头 `Idempotency-Key` 用值 |
|
||||||
|
| `status` | `PENDING` / `SENT` / `DEAD` |
|
||||||
|
| `attempts`, `last_error`, `next_retry_at` | 重试与排障 |
|
||||||
|
|
||||||
|
|
||||||
|
### 2.3 配置(示例)
|
||||||
|
|
||||||
|
|
||||||
|
| Key | 说明 |
|
||||||
|
| ----------------------------------------------- | ----------- |
|
||||||
|
| `craftlabs.platform.delivery.scheduler-enabled` | 单测可 `false` |
|
||||||
|
| `craftlabs.platform.delivery.max-attempts` | 默认 `8` |
|
||||||
|
| `craftlabs.platform.delivery.batch-size` | 每.tick 处理条数 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 平台 API:角色与方法安全
|
||||||
|
|
||||||
|
### 3.1 角色
|
||||||
|
|
||||||
|
|
||||||
|
| 角色 | 用途 |
|
||||||
|
| ----------- | ----------------------------------------------------------- |
|
||||||
|
| `SYS_ADMIN` | 全量(含 Callback) |
|
||||||
|
| `OPS` | **仅** Callback Inbox + 与运营配套能力;**不**开放合同/交付等业务写路径(由路由与菜单裁剪) |
|
||||||
|
| `DEVELOPER` | 业务+M6 只读;**不**含 Inbox |
|
||||||
|
|
||||||
|
|
||||||
|
### 3.2 安全模型
|
||||||
|
|
||||||
|
- `**@EnableMethodSecurity`** + `**@PreAuthorize**` 扛后端契约。
|
||||||
|
- `**CallbackInboxController**`:`hasAnyRole('OPS','SYS_ADMIN')`。
|
||||||
|
- `**IntegrationCatalogController**`:`hasAnyRole('OPS','SYS_ADMIN','DEVELOPER')`。
|
||||||
|
- JWT 仍由 `[JwtAuthenticationFilter](../../../services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtAuthenticationFilter.java)` 注入 `ROLE_*`。
|
||||||
|
|
||||||
|
### 3.3 演示账号
|
||||||
|
|
||||||
|
|
||||||
|
| 用户 | 密码 | 角色 |
|
||||||
|
| ------- | ------- | ----------- |
|
||||||
|
| `admin` | `admin` | `SYS_ADMIN` |
|
||||||
|
| `dev` | `dev` | `DEVELOPER` |
|
||||||
|
| `ops` | `ops` | `OPS` |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端(`delivery-platform-ui`)
|
||||||
|
|
||||||
|
### 4.1 路由 `meta.roles`
|
||||||
|
|
||||||
|
|
||||||
|
| 区域 | `meta.roles` |
|
||||||
|
| ------------------------------ | ------------------------------- |
|
||||||
|
| 首页及以下业务(客户/项目/合同/交付/SN) | `SYS_ADMIN`, `DEVELOPER` |
|
||||||
|
| `/callbacks`, `/callbacks/:id` | `SYS_ADMIN`, `OPS` |
|
||||||
|
| `/integration/*` | `SYS_ADMIN`, `DEVELOPER`, `OPS` |
|
||||||
|
|
||||||
|
|
||||||
|
### 4.2 菜单与首页
|
||||||
|
|
||||||
|
- **Pinia** `hasAnyRole(roles)`,**MainLayout** / **HomeView** 按角色显示入口,避免 OPS 误以为可点业务页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试与 DoD
|
||||||
|
|
||||||
|
|
||||||
|
| 项 | 标准 |
|
||||||
|
| ------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| 平台 | `CallbackInboxControllerTest`:`DEVELOPER` 访问 Inbox → **403**;`SYS_ADMIN`/`OPS` 仍可走通 |
|
||||||
|
| Webhook | 配置 base-url 时 **enqueue** 产生 `PENDING` 行;单测关闭 scheduler 避免后台线程 |
|
||||||
|
| 前端 | `npm run build` 通过 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 修订记录
|
||||||
|
|
||||||
|
|
||||||
|
| 日期 | 说明 |
|
||||||
|
| ---------- | ---------------------------- |
|
||||||
|
| 2026-04-06 | I7 初版:异步投递表 + OPS + 前端路由/菜单。 |
|
||||||
|
| 2026-04-06 | 闭环:实现与 [I7_IMPLEMENTATION_REVIEW.md](./I7_IMPLEMENTATION_REVIEW.md) 复盘。 |
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# I7 实现复盘 — 对照 [I7_DESIGN.md](./I7_DESIGN.md)
|
||||||
|
|
||||||
|
> **方法**:三任务闭环——架构设计 → 前后端实现 → 本复盘。
|
||||||
|
> **日期**:2026-04-06。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总评
|
||||||
|
|
||||||
|
| 主题 | 设计意图 | 实现结论 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| Webhook 异步投递 | 对比特快速 2xx;平台 POST 后台重试;可观测 `PENDING/SENT/DEAD` | **已落地**:`webhook_platform_delivery` + `PlatformDeliveryService` + `PlatformDeliveryScheduler`;配置见 `craftlabs.platform.delivery.*` 与 [RUNBOOK §10.5](../../services/RUNBOOK.md)。 |
|
||||||
|
| OPS 与 Inbox | `CallbackInboxController` 仅 `OPS`/`SYS_ADMIN` | **已落地**:`@PreAuthorize` + 演示账号 `ops/ops`。 |
|
||||||
|
| M6 只读 | `IntegrationCatalogController` 三者可读 | **已落地**:`OPS`+`SYS_ADMIN`+`DEVELOPER`。 |
|
||||||
|
| 前端 | 路由 `meta.roles` 与侧栏一致 | **已落地**:`HomeView` 过滤链接;`MainLayout` `v-if`;首页含 `OPS`。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 偏差与已知局限
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| **DEAD 行运维** | 仅 DB 字段 `last_error`/`status`;无 UI 重放按钮(I7.1 可选)。 |
|
||||||
|
| **`v-permission` 指令** | 设计可选组件级指令;当前以 **路由 + 菜单 `hasAnyRole`** 为主,足够覆盖 I7 DoD。 |
|
||||||
|
| **Playwright** | 仍未进 CI;与 [I6_CLOSEOUT](./I6_CLOSEOUT.md) 一致列为后续。 |
|
||||||
|
| **内部 mTLS** | 未在本次范围;仍共享 `X-Platform-Internal-Token`。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 验证清单(走查)
|
||||||
|
|
||||||
|
- [x] `mvn -f services/pom.xml verify`
|
||||||
|
- [x] `web/delivery-platform-ui` `npm run build`
|
||||||
|
- [x] `AuthControllerTest` ops 登录
|
||||||
|
- [x] `CallbackInboxControllerTest`:`dev` → Inbox **403**
|
||||||
|
- [x] `PlatformDeliveryEnqueueTest`:首单 Callback → 队列 **+1**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 修订记录
|
||||||
|
|
||||||
|
| 日期 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-04-06 | 初版:I7 闭环复盘。 |
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
| **I4** | `/deliveries`、`/licenses/sn`、导入 | `DeliveryBatchForm`、`SnBindDialog`、`SnStatusTimeline` | 交付与合同行;孤儿 SN 警告 | P0 交付→SN→回写 | M3/M4 P0 |
|
| **I4** | `/deliveries`、`/licenses/sn`、导入 | `DeliveryBatchForm`、`SnBindDialog`、`SnStatusTimeline` | 交付与合同行;孤儿 SN 警告 | P0 交付→SN→回写 | M3/M4 P0 |
|
||||||
| **I5** | `/callbacks`、`/integration/environments`、`product-lines` | `CallbackInboxTable`、`CallbackPayloadViewer`(脱敏) | Inbox 处置;M6 只读/受限写 | P0 列表→详情→状态 | 与 Webhook 联调或 staging |
|
| **I5** | `/callbacks`、`/integration/environments`、`product-lines` | `CallbackInboxTable`、`CallbackPayloadViewer`(脱敏) | Inbox 处置;M6 只读/受限写 | P0 列表→详情→状态 | 与 Webhook 联调或 staging |
|
||||||
| **I6** | 全链路导航与修缺陷(参见 [I6_CLOSEOUT.md](../iterations/I6_CLOSEOUT.md)) | 可选 `GlobalSearch` | 错误与空态统一;生产 `VITE_API_BASE` | P0 **BP-01~06+11** 全链路 E2E | UAT 无 P0;手册截图一致 |
|
| **I6** | 全链路导航与修缺陷(参见 [I6_CLOSEOUT.md](../iterations/I6_CLOSEOUT.md)) | 可选 `GlobalSearch` | 错误与空态统一;生产 `VITE_API_BASE` | P0 **BP-01~06+11** 全链路 E2E | UAT 无 P0;手册截图一致 |
|
||||||
|
| **I7** | `/callbacks*` 仅 **OPS/SYS_ADMIN**;菜单与首页按角色裁剪 | Pinia `hasAnyRole` | 与 `@PreAuthorize` 对齐;`ops/ops` 演示 | 与 Webhook 异步投递联调 | 参见 [I7_DESIGN.md](../iterations/I7_DESIGN.md)、[I7_IMPLEMENTATION_REVIEW.md](../iterations/I7_IMPLEMENTATION_REVIEW.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -132,4 +132,15 @@ curl -sS -o /dev/null -w "%{http_code}\n" \
|
|||||||
### 10.4 Flyway 历史表
|
### 10.4 Flyway 历史表
|
||||||
|
|
||||||
- 平台:`flyway_platform_api`(迁移含 `platform_callback_inbox`、M6 表等)。
|
- 平台:`flyway_platform_api`(迁移含 `platform_callback_inbox`、M6 表等)。
|
||||||
- Webhook:`flyway_webhook`(收据表)。**同一 PostgreSQL 实例** 下两表共存,**勿**手动改名或合并。
|
- Webhook:`flyway_webhook`(`V1` 收据表、`V2` **`webhook_platform_delivery`** 平台投递出库)。**同一 PostgreSQL 实例** 下两表共存,**勿**手动改名或合并。
|
||||||
|
|
||||||
|
### 10.5 I7:异步投递与调度(Webhook)
|
||||||
|
|
||||||
|
| 配置 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `craftlabs.platform.delivery.scheduler-enabled` | 默认 `true`;单测/特殊场景可 `false` |
|
||||||
|
| `craftlabs.platform.delivery.tick-ms` | 调度间隔(毫秒) |
|
||||||
|
| `craftlabs.platform.delivery.max-attempts` | 单条投递最大尝试次数,超限标记 **`DEAD`** |
|
||||||
|
| `craftlabs.platform.delivery.batch-size` | 每 tick 最多拉取条数 |
|
||||||
|
|
||||||
|
比特 Callback **2xx** 在收据落库与 **出站行入队** 之后返回;真正 `POST` 平台由后台线程执行。`DEAD` 行需人工依据 `last_error` 与平台侧幂等处理。
|
||||||
|
|||||||
+2
@@ -4,8 +4,10 @@ import org.mybatis.spring.annotation.MapperScan;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
|
||||||
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
||||||
|
@EnableMethodSecurity
|
||||||
@MapperScan("cn.craftlabs.platform.api.persistence")
|
@MapperScan("cn.craftlabs.platform.api.persistence")
|
||||||
public class PlatformApplication {
|
public class PlatformApplication {
|
||||||
|
|
||||||
|
|||||||
+18
-4
@@ -1,6 +1,7 @@
|
|||||||
package cn.craftlabs.platform.api.auth;
|
package cn.craftlabs.platform.api.auth;
|
||||||
|
|
||||||
import cn.craftlabs.platform.api.security.JwtService;
|
import cn.craftlabs.platform.api.security.JwtService;
|
||||||
|
import cn.craftlabs.platform.api.security.PlatformRoles;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
@@ -30,29 +31,42 @@ public class AuthController {
|
|||||||
String pass = body.getOrDefault("password", "");
|
String pass = body.getOrDefault("password", "");
|
||||||
if ("admin".equals(user) && "admin".equals(pass)) {
|
if ("admin".equals(user) && "admin".equals(pass)) {
|
||||||
String token =
|
String token =
|
||||||
jwtService.createToken(user, "管理员", List.of("SYS_ADMIN"));
|
jwtService.createToken(user, "管理员", List.of(PlatformRoles.SYS_ADMIN));
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"token",
|
"token",
|
||||||
token,
|
token,
|
||||||
"tokenType",
|
"tokenType",
|
||||||
"Bearer",
|
"Bearer",
|
||||||
"roles",
|
"roles",
|
||||||
List.of("SYS_ADMIN"),
|
List.of(PlatformRoles.SYS_ADMIN),
|
||||||
"displayName",
|
"displayName",
|
||||||
"管理员");
|
"管理员");
|
||||||
}
|
}
|
||||||
if ("dev".equals(user) && "dev".equals(pass)) {
|
if ("dev".equals(user) && "dev".equals(pass)) {
|
||||||
String token = jwtService.createToken(user, "开发账号", List.of("DEVELOPER"));
|
String token =
|
||||||
|
jwtService.createToken(user, "开发账号", List.of(PlatformRoles.DEVELOPER));
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"token",
|
"token",
|
||||||
token,
|
token,
|
||||||
"tokenType",
|
"tokenType",
|
||||||
"Bearer",
|
"Bearer",
|
||||||
"roles",
|
"roles",
|
||||||
List.of("DEVELOPER"),
|
List.of(PlatformRoles.DEVELOPER),
|
||||||
"displayName",
|
"displayName",
|
||||||
"开发账号");
|
"开发账号");
|
||||||
}
|
}
|
||||||
|
if ("ops".equals(user) && "ops".equals(pass)) {
|
||||||
|
String token = jwtService.createToken(user, "运营账号", List.of(PlatformRoles.OPS));
|
||||||
|
return Map.of(
|
||||||
|
"token",
|
||||||
|
token,
|
||||||
|
"tokenType",
|
||||||
|
"Bearer",
|
||||||
|
"roles",
|
||||||
|
List.of(PlatformRoles.OPS),
|
||||||
|
"displayName",
|
||||||
|
"运营账号");
|
||||||
|
}
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@@ -22,6 +23,7 @@ import java.time.OffsetDateTime;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/callback-inbox")
|
@RequestMapping("/api/v1/callback-inbox")
|
||||||
@Validated
|
@Validated
|
||||||
|
@PreAuthorize("hasAnyRole('OPS','SYS_ADMIN')")
|
||||||
public class CallbackInboxController {
|
public class CallbackInboxController {
|
||||||
|
|
||||||
private final CallbackInboxService callbackInboxService;
|
private final CallbackInboxService callbackInboxService;
|
||||||
|
|||||||
+2
@@ -11,11 +11,13 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/integration")
|
@RequestMapping("/api/v1/integration")
|
||||||
@Validated
|
@Validated
|
||||||
|
@PreAuthorize("hasAnyRole('OPS','SYS_ADMIN','DEVELOPER')")
|
||||||
public class IntegrationCatalogController {
|
public class IntegrationCatalogController {
|
||||||
|
|
||||||
private final IntegrationCatalogService integrationCatalogService;
|
private final IntegrationCatalogService integrationCatalogService;
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package cn.craftlabs.platform.api.security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I7:JWT {@code roles} 声明值(过滤器会加上 {@code ROLE_} 前缀)。
|
||||||
|
*/
|
||||||
|
public final class PlatformRoles {
|
||||||
|
|
||||||
|
public static final String SYS_ADMIN = "SYS_ADMIN";
|
||||||
|
public static final String DEVELOPER = "DEVELOPER";
|
||||||
|
/** 运营:Callback Inbox 等(不包含合同/交付等业务写接口的默认放宽)。 */
|
||||||
|
public static final String OPS = "OPS";
|
||||||
|
|
||||||
|
private PlatformRoles() {}
|
||||||
|
}
|
||||||
+11
@@ -28,6 +28,17 @@ class AuthControllerTest {
|
|||||||
.andExpect(jsonPath("$.token").exists());
|
.andExpect(jsonPath("$.token").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void opsLoginReturnsOpsRole() throws Exception {
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"username\":\"ops\",\"password\":\"ops\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.token").exists())
|
||||||
|
.andExpect(jsonPath("$.roles[0]").value("OPS"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loginFail() throws Exception {
|
void loginFail() throws Exception {
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
|
|||||||
+11
@@ -108,6 +108,17 @@ class CallbackInboxControllerTest {
|
|||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void developerCannotAccessCallbackInbox() throws Exception {
|
||||||
|
String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper, "dev", "dev");
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v1/callback-inbox")
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "10"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
private String minimalIngestJson(String externalMessageId) throws Exception {
|
private String minimalIngestJson(String externalMessageId) throws Exception {
|
||||||
ObjectNode root = objectMapper.createObjectNode();
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
root.put("schemaVersion", "1.0");
|
root.put("schemaVersion", "1.0");
|
||||||
|
|||||||
+11
-1
@@ -12,11 +12,21 @@ public final class JwtTestSupport {
|
|||||||
private JwtTestSupport() {}
|
private JwtTestSupport() {}
|
||||||
|
|
||||||
public static String obtainBearerToken(MockMvc mockMvc, ObjectMapper objectMapper) throws Exception {
|
public static String obtainBearerToken(MockMvc mockMvc, ObjectMapper objectMapper) throws Exception {
|
||||||
|
return obtainBearerToken(mockMvc, objectMapper, "admin", "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String obtainBearerToken(
|
||||||
|
MockMvc mockMvc, ObjectMapper objectMapper, String username, String password) throws Exception {
|
||||||
MvcResult login =
|
MvcResult login =
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
post("/api/v1/auth/login")
|
post("/api/v1/auth/login")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"username\":\"admin\",\"password\":\"admin\"}"))
|
.content(
|
||||||
|
"{\"username\":\""
|
||||||
|
+ username
|
||||||
|
+ "\",\"password\":\""
|
||||||
|
+ password
|
||||||
|
+ "\"}"))
|
||||||
.andReturn();
|
.andReturn();
|
||||||
String body = login.getResponse().getContentAsString();
|
String body = login.getResponse().getContentAsString();
|
||||||
return objectMapper.readTree(body).get("token").asText();
|
return objectMapper.readTree(body).get("token").asText();
|
||||||
|
|||||||
+4
-5
@@ -26,15 +26,15 @@ public class CallbackIngestController {
|
|||||||
public static final String HEADER_TOKEN = "x-bitanswer-token";
|
public static final String HEADER_TOKEN = "x-bitanswer-token";
|
||||||
|
|
||||||
private final CallbackReceiptService receiptService;
|
private final CallbackReceiptService receiptService;
|
||||||
private final PlatformCallbackForwarder platformCallbackForwarder;
|
private final PlatformDeliveryService platformDeliveryService;
|
||||||
|
|
||||||
@Value("${craftlabs.webhook.expected-token:}")
|
@Value("${craftlabs.webhook.expected-token:}")
|
||||||
private String expectedToken;
|
private String expectedToken;
|
||||||
|
|
||||||
public CallbackIngestController(
|
public CallbackIngestController(
|
||||||
CallbackReceiptService receiptService, PlatformCallbackForwarder platformCallbackForwarder) {
|
CallbackReceiptService receiptService, PlatformDeliveryService platformDeliveryService) {
|
||||||
this.receiptService = receiptService;
|
this.receiptService = receiptService;
|
||||||
this.platformCallbackForwarder = platformCallbackForwarder;
|
this.platformDeliveryService = platformDeliveryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/webhook/bitanswer/callback")
|
@PostMapping("/webhook/bitanswer/callback")
|
||||||
@@ -54,8 +54,7 @@ public class CallbackIngestController {
|
|||||||
int bytes = rawBody != null ? rawBody.length() : 0;
|
int bytes = rawBody != null ? rawBody.length() : 0;
|
||||||
CallbackReceiptService.ReceiptOutcome outcome = receiptService.recordReceipt(idempotencyKey, bytes);
|
CallbackReceiptService.ReceiptOutcome outcome = receiptService.recordReceipt(idempotencyKey, bytes);
|
||||||
if (outcome.type() == CallbackReceiptService.OutcomeType.INSERTED && outcome.receiptId() != null) {
|
if (outcome.type() == CallbackReceiptService.OutcomeType.INSERTED && outcome.receiptId() != null) {
|
||||||
platformCallbackForwarder.forwardAfterReceipt(
|
platformDeliveryService.enqueueAfterReceipt(servletRequest, rawBody, idempotencyKey, outcome.receiptId());
|
||||||
servletRequest, rawBody, idempotencyKey, outcome.receiptId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package cn.craftlabs.platform.webhook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发往 {@code POST /internal/v1/callback-events} 的一次投递计划。
|
||||||
|
*/
|
||||||
|
public record PlannedPlatformDelivery(String requestBodyJson, String idempotencyHeader, String traceHeadersJson) {}
|
||||||
+34
-125
@@ -1,9 +1,6 @@
|
|||||||
package cn.craftlabs.platform.webhook;
|
package cn.craftlabs.platform.webhook;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -17,17 +14,15 @@ import org.springframework.web.client.RestClientException;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收据持久化后同步投递至 delivery-platform-api(MVP:短超时 + 有限重试)。
|
* 向 {@code delivery-platform-api} 发送单次 HTTP 投递(重试由 {@link PlatformDeliveryService} 调度)。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class PlatformCallbackForwarder {
|
public class PlatformCallbackForwarder {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PlatformCallbackForwarder.class);
|
private static final Logger log = LoggerFactory.getLogger(PlatformCallbackForwarder.class);
|
||||||
|
|
||||||
private static final String SOURCE_SYSTEM = "BITANSWER";
|
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final RestClient restClient;
|
private final RestClient restClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${craftlabs.platform.internal.base-url:}")
|
@Value("${craftlabs.platform.internal.base-url:}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
@@ -40,132 +35,46 @@ public class PlatformCallbackForwarder {
|
|||||||
this.restClient = RestClient.create();
|
this.restClient = RestClient.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void forwardAfterReceipt(
|
/**
|
||||||
HttpServletRequest request,
|
* 单次 POST;成功无声返回;失败抛 {@link RestClientException} 由调用方记重试/DEAD。
|
||||||
String rawBody,
|
*/
|
||||||
String idempotencyKey,
|
public void postOnce(String requestBodyJson, String idempotencyHeader, String traceHeadersJson)
|
||||||
long webhookReceiptId) {
|
throws RestClientException {
|
||||||
if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(internalToken)) {
|
if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(internalToken)) {
|
||||||
return;
|
throw new IllegalStateException("platform base-url or token not configured");
|
||||||
}
|
}
|
||||||
JsonNode payloadNode = parsePayloadNode(rawBody);
|
|
||||||
String externalMessageId = resolveExternalMessageId(payloadNode, idempotencyKey);
|
|
||||||
if (!StringUtils.hasText(externalMessageId)) {
|
|
||||||
log.warn("platform forward skipped: no external message id");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String schemaVersion = firstNonBlank(textField(payloadNode, "schemaVersion"), "1.0");
|
|
||||||
String eventType =
|
|
||||||
firstNonBlank(
|
|
||||||
textField(payloadNode, "event"),
|
|
||||||
textField(payloadNode, "event_type"),
|
|
||||||
textField(payloadNode, "eventType"),
|
|
||||||
"unknown");
|
|
||||||
|
|
||||||
ObjectNode body = objectMapper.createObjectNode();
|
|
||||||
body.put("schemaVersion", schemaVersion);
|
|
||||||
body.put("sourceSystem", SOURCE_SYSTEM);
|
|
||||||
body.put("externalMessageId", externalMessageId.trim());
|
|
||||||
body.put("eventType", eventType);
|
|
||||||
body.set("rawPayload", payloadNode);
|
|
||||||
body.put("webhookReceiptId", String.valueOf(webhookReceiptId));
|
|
||||||
if (StringUtils.hasText(idempotencyKey)) {
|
|
||||||
body.put("idempotencyKey", idempotencyKey.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
String json;
|
|
||||||
try {
|
|
||||||
json = objectMapper.writeValueAsString(body);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("platform forward skipped: cannot serialize body {}", e.toString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String url = baseUrl.replaceAll("/+$", "") + "/internal/v1/callback-events";
|
String url = baseUrl.replaceAll("/+$", "") + "/internal/v1/callback-events";
|
||||||
String idemHeader = StringUtils.hasText(idempotencyKey) ? idempotencyKey.trim() : externalMessageId.trim();
|
restClient
|
||||||
|
.post()
|
||||||
for (int attempt = 0; attempt < 3; attempt++) {
|
.uri(url)
|
||||||
try {
|
.header("X-Platform-Internal-Token", internalToken)
|
||||||
restClient
|
.header("Idempotency-Key", idempotencyHeader)
|
||||||
.post()
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.uri(url)
|
.headers(applyTrace(traceHeadersJson))
|
||||||
.header("X-Platform-Internal-Token", internalToken)
|
.body(requestBodyJson)
|
||||||
.header("Idempotency-Key", idemHeader)
|
.retrieve()
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.toBodilessEntity();
|
||||||
.headers(copyTraceHeaders(request))
|
|
||||||
.body(json)
|
|
||||||
.retrieve()
|
|
||||||
.toBodilessEntity();
|
|
||||||
return;
|
|
||||||
} catch (RestClientException e) {
|
|
||||||
if (attempt == 2) {
|
|
||||||
log.warn("platform callback forward failed after retries: {}", e.toString());
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
Thread.sleep(200L * (attempt + 1));
|
|
||||||
} catch (InterruptedException ie) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Consumer<HttpHeaders> copyTraceHeaders(HttpServletRequest request) {
|
private Consumer<HttpHeaders> applyTrace(String traceHeadersJson) {
|
||||||
return headers -> {
|
return headers -> {
|
||||||
String tp = request.getHeader("traceparent");
|
if (!StringUtils.hasText(traceHeadersJson)) {
|
||||||
if (StringUtils.hasText(tp)) {
|
return;
|
||||||
headers.add("traceparent", tp);
|
|
||||||
}
|
}
|
||||||
String rid = request.getHeader("X-Request-Id");
|
try {
|
||||||
if (StringUtils.hasText(rid)) {
|
var node = objectMapper.readTree(traceHeadersJson);
|
||||||
headers.add("X-Request-Id", rid);
|
if (node.isObject()) {
|
||||||
|
node.fields()
|
||||||
|
.forEachRemaining(
|
||||||
|
e -> {
|
||||||
|
if (e.getValue().isTextual()) {
|
||||||
|
headers.add(e.getKey(), e.getValue().asText());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("skip trace headers: {}", e.toString());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonNode parsePayloadNode(String rawBody) {
|
|
||||||
String raw = rawBody != null ? rawBody : "";
|
|
||||||
try {
|
|
||||||
return objectMapper.readTree(raw);
|
|
||||||
} catch (Exception e) {
|
|
||||||
ObjectNode wrapper = objectMapper.createObjectNode();
|
|
||||||
wrapper.put("_raw", raw);
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String resolveExternalMessageId(JsonNode payloadNode, String idempotencyKey) {
|
|
||||||
String fromPayload =
|
|
||||||
firstNonBlank(
|
|
||||||
textField(payloadNode, "message_id"),
|
|
||||||
textField(payloadNode, "messageId"),
|
|
||||||
payloadNode.isObject() ? textField(payloadNode, "id") : null);
|
|
||||||
return firstNonBlank(fromPayload, idempotencyKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String textField(JsonNode node, String field) {
|
|
||||||
if (node == null || !node.isObject()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
JsonNode n = node.get(field);
|
|
||||||
if (n != null && n.isTextual()) {
|
|
||||||
String t = n.asText();
|
|
||||||
return StringUtils.hasText(t) ? t.trim() : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String firstNonBlank(String... values) {
|
|
||||||
if (values == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (String v : values) {
|
|
||||||
if (StringUtils.hasText(v)) {
|
|
||||||
return v.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package cn.craftlabs.platform.webhook;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 BitAnswer 原始 body 构造发往平台的内部 API 正文与幂等键。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PlatformCallbackRequestPlanner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PlatformCallbackRequestPlanner.class);
|
||||||
|
private static final String SOURCE_SYSTEM = "BITANSWER";
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public PlatformCallbackRequestPlanner(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<PlannedPlatformDelivery> plan(
|
||||||
|
HttpServletRequest request,
|
||||||
|
String rawBody,
|
||||||
|
String idempotencyKey,
|
||||||
|
long webhookReceiptId) {
|
||||||
|
JsonNode payloadNode = parsePayloadNode(rawBody);
|
||||||
|
String externalMessageId = resolveExternalMessageId(payloadNode, idempotencyKey);
|
||||||
|
if (!StringUtils.hasText(externalMessageId)) {
|
||||||
|
log.warn("platform enqueue skipped: no external message id");
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String schemaVersion = firstNonBlank(textField(payloadNode, "schemaVersion"), "1.0");
|
||||||
|
String eventType =
|
||||||
|
firstNonBlank(
|
||||||
|
textField(payloadNode, "event"),
|
||||||
|
textField(payloadNode, "event_type"),
|
||||||
|
textField(payloadNode, "eventType"),
|
||||||
|
"unknown");
|
||||||
|
|
||||||
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
|
body.put("schemaVersion", schemaVersion);
|
||||||
|
body.put("sourceSystem", SOURCE_SYSTEM);
|
||||||
|
body.put("externalMessageId", externalMessageId.trim());
|
||||||
|
body.put("eventType", eventType);
|
||||||
|
body.set("rawPayload", payloadNode);
|
||||||
|
body.put("webhookReceiptId", String.valueOf(webhookReceiptId));
|
||||||
|
if (StringUtils.hasText(idempotencyKey)) {
|
||||||
|
body.put("idempotencyKey", idempotencyKey.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String json;
|
||||||
|
try {
|
||||||
|
json = objectMapper.writeValueAsString(body);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("platform enqueue skipped: cannot serialize body {}", e.toString());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String idemHeader = StringUtils.hasText(idempotencyKey) ? idempotencyKey.trim() : externalMessageId.trim();
|
||||||
|
String traceJson = buildTraceJson(request);
|
||||||
|
return Optional.of(new PlannedPlatformDelivery(json, idemHeader, traceJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildTraceJson(HttpServletRequest request) {
|
||||||
|
Map<String, String> m = new LinkedHashMap<>();
|
||||||
|
String tp = request.getHeader("traceparent");
|
||||||
|
if (StringUtils.hasText(tp)) {
|
||||||
|
m.put("traceparent", tp.trim());
|
||||||
|
}
|
||||||
|
String rid = request.getHeader("X-Request-Id");
|
||||||
|
if (StringUtils.hasText(rid)) {
|
||||||
|
m.put("X-Request-Id", rid.trim());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return m.isEmpty() ? null : objectMapper.writeValueAsString(m);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode parsePayloadNode(String rawBody) {
|
||||||
|
String raw = rawBody != null ? rawBody : "";
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(raw);
|
||||||
|
} catch (Exception e) {
|
||||||
|
ObjectNode wrapper = objectMapper.createObjectNode();
|
||||||
|
wrapper.put("_raw", raw);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolveExternalMessageId(JsonNode payloadNode, String idempotencyKey) {
|
||||||
|
String fromPayload =
|
||||||
|
firstNonBlank(
|
||||||
|
textField(payloadNode, "message_id"),
|
||||||
|
textField(payloadNode, "messageId"),
|
||||||
|
payloadNode.isObject() ? textField(payloadNode, "id") : null);
|
||||||
|
return firstNonBlank(fromPayload, idempotencyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textField(JsonNode node, String field) {
|
||||||
|
if (node == null || !node.isObject()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonNode n = node.get(field);
|
||||||
|
if (n != null && n.isTextual()) {
|
||||||
|
String t = n.asText();
|
||||||
|
return StringUtils.hasText(t) ? t.trim() : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String v : values) {
|
||||||
|
if (StringUtils.hasText(v)) {
|
||||||
|
return v.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package cn.craftlabs.platform.webhook;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I7:周期拉取 {@link PlatformDeliveryService} 待发送行。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "craftlabs.platform.delivery.scheduler-enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class PlatformDeliveryScheduler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PlatformDeliveryScheduler.class);
|
||||||
|
|
||||||
|
private final PlatformDeliveryService deliveryService;
|
||||||
|
|
||||||
|
public PlatformDeliveryScheduler(PlatformDeliveryService deliveryService) {
|
||||||
|
this.deliveryService = deliveryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${craftlabs.platform.delivery.tick-ms:5000}")
|
||||||
|
public void tick() {
|
||||||
|
try {
|
||||||
|
deliveryService.processDueBatch();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("platform delivery batch failed: {}", e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
package cn.craftlabs.platform.webhook;
|
||||||
|
|
||||||
|
import cn.craftlabs.platform.webhook.persistence.WebhookPlatformDelivery;
|
||||||
|
import cn.craftlabs.platform.webhook.persistence.WebhookPlatformDeliveryMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I7:平台投递入库 + 异步拉取发送。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PlatformDeliveryService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PlatformDeliveryService.class);
|
||||||
|
|
||||||
|
public static final String STATUS_PENDING = "PENDING";
|
||||||
|
public static final String STATUS_SENT = "SENT";
|
||||||
|
public static final String STATUS_DEAD = "DEAD";
|
||||||
|
|
||||||
|
private final PlatformCallbackRequestPlanner planner;
|
||||||
|
private final PlatformCallbackForwarder forwarder;
|
||||||
|
private final WebhookPlatformDeliveryMapper deliveryMapper;
|
||||||
|
|
||||||
|
@Value("${craftlabs.platform.internal.base-url:}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
@Value("${craftlabs.platform.internal.token:}")
|
||||||
|
private String internalToken;
|
||||||
|
|
||||||
|
@Value("${craftlabs.platform.delivery.max-attempts:8}")
|
||||||
|
private int maxAttempts;
|
||||||
|
|
||||||
|
@Value("${craftlabs.platform.delivery.batch-size:20}")
|
||||||
|
private int batchSize;
|
||||||
|
|
||||||
|
public PlatformDeliveryService(
|
||||||
|
PlatformCallbackRequestPlanner planner,
|
||||||
|
PlatformCallbackForwarder forwarder,
|
||||||
|
WebhookPlatformDeliveryMapper deliveryMapper) {
|
||||||
|
this.planner = planner;
|
||||||
|
this.forwarder = forwarder;
|
||||||
|
this.deliveryMapper = deliveryMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void enqueueAfterReceipt(
|
||||||
|
HttpServletRequest request, String rawBody, String idempotencyKey, long receiptId) {
|
||||||
|
if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(internalToken)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var planned = planner.plan(request, rawBody, idempotencyKey, receiptId);
|
||||||
|
if (planned.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlannedPlatformDelivery p = planned.get();
|
||||||
|
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
|
var row = new WebhookPlatformDelivery();
|
||||||
|
row.setReceiptId(receiptId);
|
||||||
|
row.setIdempotencyKey(p.idempotencyHeader());
|
||||||
|
row.setRequestBody(p.requestBodyJson());
|
||||||
|
row.setTraceHeadersJson(p.traceHeadersJson());
|
||||||
|
row.setStatus(STATUS_PENDING);
|
||||||
|
row.setAttempts(0);
|
||||||
|
row.setNextRetryAt(null);
|
||||||
|
row.setCreatedAt(now);
|
||||||
|
row.setUpdatedAt(now);
|
||||||
|
try {
|
||||||
|
deliveryMapper.insert(row);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("platform delivery enqueue failed receiptId={}: {}", receiptId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void processDueBatch() {
|
||||||
|
if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(internalToken)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<WebhookPlatformDelivery> due = deliveryMapper.selectPendingDue(batchSize);
|
||||||
|
for (WebhookPlatformDelivery d : due) {
|
||||||
|
processOne(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processOne(WebhookPlatformDelivery d) {
|
||||||
|
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
|
try {
|
||||||
|
String idem =
|
||||||
|
StringUtils.hasText(d.getIdempotencyKey())
|
||||||
|
? d.getIdempotencyKey().trim()
|
||||||
|
: inferIdempotencyFromBody(d.getRequestBody());
|
||||||
|
forwarder.postOnce(d.getRequestBody(), idem, d.getTraceHeadersJson());
|
||||||
|
d.setStatus(STATUS_SENT);
|
||||||
|
d.setUpdatedAt(now);
|
||||||
|
d.setLastError(null);
|
||||||
|
deliveryMapper.updateById(d);
|
||||||
|
} catch (RestClientException | IllegalStateException e) {
|
||||||
|
int nextAttempt = (d.getAttempts() == null ? 0 : d.getAttempts()) + 1;
|
||||||
|
d.setAttempts(nextAttempt);
|
||||||
|
d.setLastError(trimError(e));
|
||||||
|
d.setUpdatedAt(now);
|
||||||
|
if (nextAttempt >= maxAttempts) {
|
||||||
|
d.setStatus(STATUS_DEAD);
|
||||||
|
d.setNextRetryAt(null);
|
||||||
|
log.warn("platform delivery DEAD id={} attempts={} err={}", d.getId(), nextAttempt, d.getLastError());
|
||||||
|
} else {
|
||||||
|
long backoffMs = Math.min(60_000L, 500L * (1L << Math.min(nextAttempt, 10)));
|
||||||
|
d.setNextRetryAt(now.plusNanos(backoffMs * 1_000_000L));
|
||||||
|
log.debug(
|
||||||
|
"platform delivery retry scheduled id={} attempt={} next={}",
|
||||||
|
d.getId(),
|
||||||
|
nextAttempt,
|
||||||
|
d.getNextRetryAt());
|
||||||
|
}
|
||||||
|
deliveryMapper.updateById(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String inferIdempotencyFromBody(String json) {
|
||||||
|
if (!StringUtils.hasText(json)) {
|
||||||
|
return "missing-body";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var om = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
var n = om.readTree(json);
|
||||||
|
if (n.hasNonNull("externalMessageId")) {
|
||||||
|
return n.get("externalMessageId").asText();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimError(Throwable e) {
|
||||||
|
String m = e.getMessage();
|
||||||
|
if (!StringUtils.hasText(m)) {
|
||||||
|
m = e.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
return m.length() > 2000 ? m.substring(0, 2000) : m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单测:出站队列行数。 */
|
||||||
|
public long countAll() {
|
||||||
|
return deliveryMapper.selectCount(Wrappers.emptyWrapper());
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -3,8 +3,10 @@ package cn.craftlabs.platform.webhook;
|
|||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
@MapperScan("cn.craftlabs.platform.webhook.persistence")
|
@MapperScan("cn.craftlabs.platform.webhook.persistence")
|
||||||
public class WebhookApplication {
|
public class WebhookApplication {
|
||||||
|
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package cn.craftlabs.platform.webhook.persistence;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@TableName("webhook_platform_delivery")
|
||||||
|
public class WebhookPlatformDelivery {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long receiptId;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestBody;
|
||||||
|
private String traceHeadersJson;
|
||||||
|
/** PENDING / SENT / DEAD */
|
||||||
|
private String status;
|
||||||
|
private Integer attempts;
|
||||||
|
private String lastError;
|
||||||
|
private OffsetDateTime nextRetryAt;
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getReceiptId() {
|
||||||
|
return receiptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReceiptId(Long receiptId) {
|
||||||
|
this.receiptId = receiptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIdempotencyKey() {
|
||||||
|
return idempotencyKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIdempotencyKey(String idempotencyKey) {
|
||||||
|
this.idempotencyKey = idempotencyKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequestBody() {
|
||||||
|
return requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequestBody(String requestBody) {
|
||||||
|
this.requestBody = requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTraceHeadersJson() {
|
||||||
|
return traceHeadersJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTraceHeadersJson(String traceHeadersJson) {
|
||||||
|
this.traceHeadersJson = traceHeadersJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAttempts() {
|
||||||
|
return attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAttempts(Integer attempts) {
|
||||||
|
this.attempts = attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastError() {
|
||||||
|
return lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastError(String lastError) {
|
||||||
|
this.lastError = lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getNextRetryAt() {
|
||||||
|
return nextRetryAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNextRetryAt(OffsetDateTime nextRetryAt) {
|
||||||
|
this.nextRetryAt = nextRetryAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package cn.craftlabs.platform.webhook.persistence;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface WebhookPlatformDeliveryMapper extends BaseMapper<WebhookPlatformDelivery> {
|
||||||
|
|
||||||
|
@Select(
|
||||||
|
"""
|
||||||
|
SELECT * FROM webhook_platform_delivery
|
||||||
|
WHERE status = 'PENDING'
|
||||||
|
AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP)
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT #{limit}
|
||||||
|
""")
|
||||||
|
List<WebhookPlatformDelivery> selectPendingDue(@Param("limit") int limit);
|
||||||
|
}
|
||||||
@@ -34,3 +34,8 @@ craftlabs:
|
|||||||
internal:
|
internal:
|
||||||
base-url: ${PLATFORM_INTERNAL_BASE_URL:}
|
base-url: ${PLATFORM_INTERNAL_BASE_URL:}
|
||||||
token: ${CRAFTLABS_PLATFORM_INTERNAL_TOKEN:}
|
token: ${CRAFTLABS_PLATFORM_INTERNAL_TOKEN:}
|
||||||
|
delivery:
|
||||||
|
scheduler-enabled: true
|
||||||
|
tick-ms: ${PLATFORM_DELIVERY_TICK_MS:5000}
|
||||||
|
max-attempts: ${PLATFORM_DELIVERY_MAX_ATTEMPTS:8}
|
||||||
|
batch-size: ${PLATFORM_DELIVERY_BATCH_SIZE:20}
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
-- I7:平台投递异步出库(可重试 / DEAD)
|
||||||
|
CREATE TABLE webhook_platform_delivery (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
receipt_id BIGINT NOT NULL,
|
||||||
|
idempotency_key VARCHAR(512),
|
||||||
|
request_body TEXT NOT NULL,
|
||||||
|
trace_headers_json TEXT,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
last_error VARCHAR(2048),
|
||||||
|
next_retry_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_webhook_platform_delivery_receipt UNIQUE (receipt_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_webhook_platform_delivery_pending ON webhook_platform_delivery (status, next_retry_at, id);
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package cn.craftlabs.platform.webhook;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class PlatformDeliveryEnqueueTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PlatformDeliveryService platformDeliveryService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstCallbackInsertsPendingDeliveryRow() throws Exception {
|
||||||
|
long before = platformDeliveryService.countAll();
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/webhook/bitanswer/callback")
|
||||||
|
.header(CallbackIngestController.HEADER_TOKEN, "test-secret")
|
||||||
|
.header("Idempotency-Key", "enqueue-it-" + System.nanoTime())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"message_id\":\"msg-e1\",\"event\":\"sn:x\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
assertThat(platformDeliveryService.countAll()).isEqualTo(before + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,3 +18,9 @@ mybatis-plus:
|
|||||||
craftlabs:
|
craftlabs:
|
||||||
webhook:
|
webhook:
|
||||||
expected-token: test-secret
|
expected-token: test-secret
|
||||||
|
platform:
|
||||||
|
internal:
|
||||||
|
base-url: http://127.0.0.1:65509
|
||||||
|
token: unit-test-internal-token
|
||||||
|
delivery:
|
||||||
|
scheduler-enabled: false
|
||||||
|
|||||||
@@ -6,3 +6,18 @@ CREATE TABLE IF NOT EXISTS webhook_callback_receipt (
|
|||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT uq_webhook_idempotency UNIQUE (idempotency_key)
|
CONSTRAINT uq_webhook_idempotency UNIQUE (idempotency_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webhook_platform_delivery (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
receipt_id BIGINT NOT NULL,
|
||||||
|
idempotency_key VARCHAR(512),
|
||||||
|
request_body TEXT NOT NULL,
|
||||||
|
trace_headers_json TEXT,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
last_error VARCHAR(2048),
|
||||||
|
next_retry_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_webhook_platform_delivery_receipt UNIQUE (receipt_id)
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,28 +6,28 @@
|
|||||||
<el-menu-item index="/">
|
<el-menu-item index="/">
|
||||||
<span>首页</span>
|
<span>首页</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/customers">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'DEVELOPER'])" index="/customers">
|
||||||
<span>客户管理</span>
|
<span>客户管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/projects">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'DEVELOPER'])" index="/projects">
|
||||||
<span>项目管理</span>
|
<span>项目管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/contracts">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'DEVELOPER'])" index="/contracts">
|
||||||
<span>合同管理</span>
|
<span>合同管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/deliveries">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'DEVELOPER'])" index="/deliveries">
|
||||||
<span>交付管理</span>
|
<span>交付管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/licenses/sn">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'DEVELOPER'])" index="/licenses/sn">
|
||||||
<span>许可 SN</span>
|
<span>许可 SN</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/callbacks">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'OPS'])" index="/callbacks">
|
||||||
<span>Callback 收件箱</span>
|
<span>Callback 收件箱</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/integration/environments">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'DEVELOPER', 'OPS'])" index="/integration/environments">
|
||||||
<span>集成环境</span>
|
<span>集成环境</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/integration/product-lines">
|
<el-menu-item v-if="auth.hasAnyRole(['SYS_ADMIN', 'DEVELOPER', 'OPS'])" index="/integration/product-lines">
|
||||||
<span>产品线</span>
|
<span>产品线</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const routes = [
|
|||||||
path: "",
|
path: "",
|
||||||
name: "home",
|
name: "home",
|
||||||
component: () => import("../views/HomeView.vue"),
|
component: () => import("../views/HomeView.vue"),
|
||||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"] },
|
meta: { roles: ["SYS_ADMIN", "DEVELOPER", "OPS"] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "customers",
|
path: "customers",
|
||||||
@@ -66,25 +66,25 @@ const routes = [
|
|||||||
path: "integration/environments",
|
path: "integration/environments",
|
||||||
name: "integration-environments",
|
name: "integration-environments",
|
||||||
component: () => import("../views/IntegrationEnvironmentsView.vue"),
|
component: () => import("../views/IntegrationEnvironmentsView.vue"),
|
||||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "集成环境" },
|
meta: { roles: ["SYS_ADMIN", "DEVELOPER", "OPS"], title: "集成环境" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "integration/product-lines",
|
path: "integration/product-lines",
|
||||||
name: "integration-product-lines",
|
name: "integration-product-lines",
|
||||||
component: () => import("../views/IntegrationProductLinesView.vue"),
|
component: () => import("../views/IntegrationProductLinesView.vue"),
|
||||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "产品线" },
|
meta: { roles: ["SYS_ADMIN", "DEVELOPER", "OPS"], title: "产品线" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "callbacks/:id",
|
path: "callbacks/:id",
|
||||||
name: "callback-inbox-detail",
|
name: "callback-inbox-detail",
|
||||||
component: () => import("../views/CallbackInboxDetailView.vue"),
|
component: () => import("../views/CallbackInboxDetailView.vue"),
|
||||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "Callback 详情" },
|
meta: { roles: ["SYS_ADMIN", "OPS"], title: "Callback 详情" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "callbacks",
|
path: "callbacks",
|
||||||
name: "callback-inbox",
|
name: "callback-inbox",
|
||||||
component: () => import("../views/CallbackInboxView.vue"),
|
component: () => import("../views/CallbackInboxView.vue"),
|
||||||
meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "Callback 收件箱" },
|
meta: { roles: ["SYS_ADMIN", "OPS"], title: "Callback 收件箱" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "contracts/new",
|
path: "contracts/new",
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
displayName: "",
|
displayName: "",
|
||||||
roles: [],
|
roles: [],
|
||||||
}),
|
}),
|
||||||
|
getters: {
|
||||||
|
hasAnyRole: (state) => {
|
||||||
|
return (roleList) => {
|
||||||
|
const need = roleList || [];
|
||||||
|
const have = state.roles || [];
|
||||||
|
return need.some((r) => have.includes(r));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async login(username, password) {
|
async login(username, password) {
|
||||||
const { data } = await axios.post("/api/v1/auth/login", { username, password });
|
const { data } = await axios.post("/api/v1/auth/login", { username, password });
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
<div class="home">
|
<div class="home">
|
||||||
<el-card class="block">
|
<el-card class="block">
|
||||||
<el-alert
|
<el-alert
|
||||||
title="交付平台(I6 UAT):以下为已实现模块的快速入口;登录态为 JWT Bearer"
|
title="交付平台(I7):按角色展示入口;Callback 仅 OPS / SYS_ADMIN"
|
||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
:closable="false"
|
:closable="false"
|
||||||
/>
|
/>
|
||||||
<p class="meta">用户:{{ auth.displayName }},角色:{{ auth.roles.join(", ") || "—" }}</p>
|
<p class="meta">用户:{{ auth.displayName }},角色:{{ auth.roles.join(", ") || "—" }}</p>
|
||||||
<div class="quick-links" aria-label="模块导航">
|
<div class="quick-links" aria-label="模块导航">
|
||||||
<router-link v-for="l in moduleLinks" :key="l.to" class="ql" :to="l.to">
|
<router-link v-for="l in visibleModuleLinks" :key="l.to" class="ql" :to="l.to">
|
||||||
{{ l.label }}
|
{{ l.label }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useAuthStore } from "../stores/auth";
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
|
||||||
@@ -31,18 +31,20 @@ const auth = useAuthStore();
|
|||||||
const pingBody = ref("");
|
const pingBody = ref("");
|
||||||
const pingLoading = ref(false);
|
const pingLoading = ref(false);
|
||||||
|
|
||||||
/** I6:全链路导航锚点,与 MainLayout 菜单一致 */
|
/** I7:与 MainLayout / 路由 meta 一致 */
|
||||||
const moduleLinks = [
|
const allModuleLinks = [
|
||||||
{ to: "/customers", label: "客户" },
|
{ to: "/customers", label: "客户", roles: ["SYS_ADMIN", "DEVELOPER"] },
|
||||||
{ to: "/projects", label: "项目" },
|
{ to: "/projects", label: "项目", roles: ["SYS_ADMIN", "DEVELOPER"] },
|
||||||
{ to: "/contracts", label: "合同" },
|
{ to: "/contracts", label: "合同", roles: ["SYS_ADMIN", "DEVELOPER"] },
|
||||||
{ to: "/deliveries", label: "交付" },
|
{ to: "/deliveries", label: "交付", roles: ["SYS_ADMIN", "DEVELOPER"] },
|
||||||
{ to: "/licenses/sn", label: "许可 SN" },
|
{ to: "/licenses/sn", label: "许可 SN", roles: ["SYS_ADMIN", "DEVELOPER"] },
|
||||||
{ to: "/callbacks", label: "Callback 收件箱" },
|
{ to: "/callbacks", label: "Callback 收件箱", roles: ["SYS_ADMIN", "OPS"] },
|
||||||
{ to: "/integration/environments", label: "集成环境" },
|
{ to: "/integration/environments", label: "集成环境", roles: ["SYS_ADMIN", "DEVELOPER", "OPS"] },
|
||||||
{ to: "/integration/product-lines", label: "产品线" },
|
{ to: "/integration/product-lines", label: "产品线", roles: ["SYS_ADMIN", "DEVELOPER", "OPS"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const visibleModuleLinks = computed(() => allModuleLinks.filter((l) => auth.hasAnyRole(l.roles)));
|
||||||
|
|
||||||
onMounted(() => auth.restoreAxiosAuth());
|
onMounted(() => auth.restoreAxiosAuth());
|
||||||
|
|
||||||
async function ping() {
|
async function ping() {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-button type="primary" native-type="submit" :loading="loading" block>登录</el-button>
|
<el-button type="primary" native-type="submit" :loading="loading" block>登录</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
<p class="hint">演示:admin / admin(SYS_ADMIN);dev / dev(DEVELOPER)</p>
|
<p class="hint">演示:admin / admin(SYS_ADMIN);dev / dev(DEVELOPER);ops / ops(OPS,Callback 运营)</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user