From 5fe7181b35ca53ca1cec95f2ce541103c891eea4 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 6 Apr 2026 23:01:10 +0800 Subject: [PATCH] feat(i7): async webhook delivery queue, OPS RBAC, UI role routing; docs and runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/engineering/iterations/I5_I6_DESIGN.md | 103 ++++++++---- .../iterations/I6_IMPLEMENTATION_REVIEW.md | 1 + docs/engineering/iterations/I7_DESIGN.md | 122 ++++++++++++++ .../iterations/I7_IMPLEMENTATION_REVIEW.md | 44 +++++ .../tracks/02-frontend-platform-ui.md | 1 + services/RUNBOOK.md | 13 +- .../platform/api/PlatformApplication.java | 2 + .../platform/api/auth/AuthController.java | 22 ++- .../api/callback/CallbackInboxController.java | 2 + .../IntegrationCatalogController.java | 2 + .../platform/api/security/PlatformRoles.java | 14 ++ .../platform/api/auth/AuthControllerTest.java | 11 ++ .../callback/CallbackInboxControllerTest.java | 11 ++ .../platform/api/support/JwtTestSupport.java | 12 +- .../webhook/CallbackIngestController.java | 9 +- .../webhook/PlannedPlatformDelivery.java | 6 + .../webhook/PlatformCallbackForwarder.java | 159 ++++-------------- .../PlatformCallbackRequestPlanner.java | 134 +++++++++++++++ .../webhook/PlatformDeliveryScheduler.java | 32 ++++ .../webhook/PlatformDeliveryService.java | 154 +++++++++++++++++ .../platform/webhook/WebhookApplication.java | 2 + .../persistence/WebhookPlatformDelivery.java | 114 +++++++++++++ .../WebhookPlatformDeliveryMapper.java | 22 +++ .../src/main/resources/application.yml | 5 + .../V2__webhook_platform_delivery.sql | 17 ++ .../webhook/PlatformDeliveryEnqueueTest.java | 36 ++++ .../src/test/resources/application.yml | 6 + .../test/resources/schema-webhook-test.sql | 15 ++ .../src/layout/MainLayout.vue | 16 +- web/delivery-platform-ui/src/router/index.js | 10 +- web/delivery-platform-ui/src/stores/auth.js | 9 + .../src/views/HomeView.vue | 28 +-- .../src/views/LoginView.vue | 2 +- 33 files changed, 936 insertions(+), 200 deletions(-) create mode 100644 docs/engineering/iterations/I7_DESIGN.md create mode 100644 docs/engineering/iterations/I7_IMPLEMENTATION_REVIEW.md create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/PlatformRoles.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlannedPlatformDelivery.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackRequestPlanner.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryScheduler.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDelivery.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDeliveryMapper.java create mode 100644 services/license-webhook-ingress/src/main/resources/db/migration/V2__webhook_platform_delivery.sql create mode 100644 services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/PlatformDeliveryEnqueueTest.java diff --git a/docs/engineering/iterations/I5_I6_DESIGN.md b/docs/engineering/iterations/I5_I6_DESIGN.md index 2911c8f..6ce7213 100644 --- a/docs/engineering/iterations/I5_I6_DESIGN.md +++ b/docs/engineering/iterations/I5_I6_DESIGN.md @@ -2,13 +2,14 @@ > **仓库**:`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`。 -> **Webhook 工程名**:本工作区已实现为 `license-webhook-ingress`([`services/README.md`](../../../services/README.md):`webhook_callback_receipt`、`Idempotency-Key`)。 +> **实现锚点**(与现有模式一致):`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`)。 --- ## 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 版本。 | @@ -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-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 切片 ### A.1 问题与目标结果 + | 维度 | 说明 | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **业务问题** | 比特规则 **HTTPS Callback** 需 **不断链、可审计、可运营处置**;平台侧需统一收件箱,避免只在 Webhook 边缘落库不可见。 | | **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)。 | -| **schemaVersion** | 事件体或头携带 **`schemaVersion`**(与轨道 A 的 `X-Event-Schema-Version` 二选一或并存,**须写 ADR 定一种主口径**);平台拒绝无法识别的 major 版本时返回 **4xx** 并记录可观测字段,避免静默损坏。 | +| **幂等** | 与轨道 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** 并记录可观测字段,避免静默损坏。 | + ### A.2 数据模型(`delivery-platform-api`,PostgreSQL + Flyway) -命名与现有表一致采用 **`platform_*` 前缀**(见 `V1`~`V4` 迁移)。 +命名与现有表一致采用 `**platform_*` 前缀**(见 `V1`~`V4` 迁移)。 #### A.2.1 `platform_callback_inbox`(M5 Inbox P0) + | 列(示例) | 类型/说明 | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | `id` | UUID / BIGSERIAL PK | @@ -45,7 +50,7 @@ | **唯一约束** | `UNIQUE (source_system, external_message_id)` | | `schema_version` | VARCHAR,与 payload 解析版本对齐 | | `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 **脱敏展示** | | `idempotency_key` | VARCHAR NULL,审计与排障 | | **关联(均可 NULL,支撑 M5-F04)** | `license_sn_id` → `platform_license_sn`;`contract_id` / `project_id`(若已有表);解析字段如 `sn_code`、`mid` 等冗余列便于列表筛选 | @@ -53,51 +58,60 @@ | `received_at` | 平台收件时间 | | `processed_at` / `processed_by_user_id` | 运营处置 | | `failure_reason` / `operator_note` | 文本,P1 可扩展「失败原因分类」字典 | -| 标准审计 | 与现有实体一致可补充 `created_at`/`updated_at`;关键状态迁移建议走 **`AuditService`**(扩展 `AuditEntityTypes` / `AuditActions`) | +| 标准审计 | 与现有实体一致可补充 `created_at`/`updated_at`;关键状态迁移建议走 `**AuditService`**(扩展 `AuditEntityTypes` / `AuditActions`) | + #### A.2.2 M6 最小只读支撑表 仅包含 **I5 UI 与 Inbox 筛选** 所需字段;**不做** M6-F03~F06 全量。 -| 表 | 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** 简化) | + +| 表 | 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** 简化) | + **MVP 裁减**:比特产品/模版/业务 ID 映射(M6-F03)、特征映射(M6-F04)、JSON 模板与发布记录(M6-F05/F06)**推迟**至 V1.1 或 Mid,除非比特联调硬依赖。 ### 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/{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 | + **可选**:`POST /api/v1/callback-inbox/simulate`(**仅非生产**,对应 M5-F10 P2 — **I5 若排期紧可不做**,改用 curl → Webhook → 平台链)。 **M6 只读** + | 方法 | 路径 | 说明 | | ----- | ---------------------------------------------------------------- | ------ | | `GET` | `/api/v1/integration/environments` | 列表/分页 | | `GET` | `/api/v1/integration/product-lines` | 列表/分页 | | `GET` | `/api/v1/integration/environments/{id}`、`.../product-lines/{id}` | 详情(按需) | + 写接口(维护环境/产品线)MVP 可 **仅种子数据 + Flyway** 或 **管理员 POST**(若 I5 周可交付则加 `POST/PUT`,否则 **推迟**)。 ### A.4 内部 API(平台服务间,`license-webhook-ingress` → `delivery-platform-api`) + | 项 | 说明 | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | **路径** | `POST /internal/v1/callback-events` | -| **认证(MVP 推荐)** | 共享密钥:**`X-Platform-Internal-Token`**(或 `Authorization: Bearer `),配置与 `PLATFORM_JWT_SECRET` 分离;**生产建议路线图**:mTLS 或双向签名(文档中注明 **I6 Runbook:轮换步骤**)。 | -| **幂等** | 请求头 **`Idempotency-Key`** + 体 **`sourceSystem` + `externalMessageId`** 与 Inbox 唯一键一致;重复 POST → **200** 且 body 指向同一 `inboxId`。 | +| **认证(MVP 推荐)** | 共享密钥:`**X-Platform-Internal-Token`**(或 `Authorization: Bearer `),配置与 `PLATFORM_JWT_SECRET` 分离;**生产建议路线图**:mTLS 或双向签名(文档中注明 **I6 Runbook:轮换步骤**)。 | +| **幂等** | 请求头 `**Idempotency-Key`** + 体 `**sourceSystem` + `externalMessageId**` 与 Inbox 唯一键一致;重复 POST → **200** 且 body 指向同一 `inboxId`。 | | **行为** | 校验 `schemaVersion` → 插入或跳过(幂等)→ 尝试解析并 **填充关联列**(失败则 `status=PENDING` 保留人工挂接)。 | + **请求 / 响应 JSON 示例(示意)** ```http @@ -128,38 +142,44 @@ Content-Type: application/json ### A.5 `license-webhook-ingress` 链路 + | 步骤 | 说明 | | ---------------- | ------------------------------------------------------------------------------------------------------------------------- | | 1 | **验签 / token**(现有 `x-bitanswer-token` 与 `CRAFTLABS_WEBHOOK_EXPECTED_TOKEN`) | -| 2 | **持久化收据**(现有 **`webhook_callback_receipt`** + **`Idempotency-Key`** 幂等) | -| 3 | **HTTP 转发** 至平台 `POST /internal/v1/callback-events`:**带重试**(指数退避、最大次数、超时);贯通 **`traceparent` / `X-Request-Id`**(轨道 A §3) | +| 2 | **持久化收据**(现有 `**webhook_callback_receipt`** + `**Idempotency-Key**` 幂等) | +| 3 | **HTTP 转发** 至平台 `POST /internal/v1/callback-events`:**带重试**(指数退避、最大次数、超时);贯通 `**traceparent` / `X-Request-Id`**(轨道 A §3) | | **对比特的 HTTP 响应** | 与轨道 A 一致:**2xx 须在收据已持久化(或可靠入队)之后**再返回。**MVP 推荐**:先落库收据即对比特 **2xx**,平台投递 **异步重试**;若 **同步** 转发,须 **短超时** 且平台幂等,避免比特侧超时重放放大。 | | **平台非 2xx** | Webhook 侧重试;仍失败则记 **DLQ/失败计数**(日志 + DB 字段,**M5-F08 完整监控可推迟**);**不**因平台暂时不可用而对已持久化收据重复向比特报错(若已 2xx)。 | + ### 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)。 | -| **内部路由** | **`/internal/**` 建议排除在默认 springdoc 分组之外**,或打上 tag **`internal`** 且 **生产禁用**该分组(与「对外契约」分离,避免集成方误用)。 | -| **校验** | `UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest`(见 [`contracts/README.md`](../../../contracts/README.md))。 | +| **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`** 且 **生产禁用**该分组(与「对外契约」分离,避免集成方误用)。 | +| **校验** | `UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest`(见 `[contracts/README.md](../../../contracts/README.md)`)。 | + ### A.7 前端(`web/delivery-platform-ui`) + | 路由 | 页面职责 | | ---------------------------- | ---------------------------------------------------- | | `/callbacks` | Inbox 列表、`CallbackInboxTable`;跳转详情 | -| `/callbacks/:id` | 详情 + **`CallbackPayloadViewer`(脱敏)**;状态 PATCH、可选人工挂接 | +| `/callbacks/:id` | 详情 + `**CallbackPayloadViewer`(脱敏)**;状态 PATCH、可选人工挂接 | | `/integration/environments` | M6 环境只读表 | | `/integration/product-lines` | 产品线只读表 | + 路由 meta:**权限码与 I1 壳一致**;菜单对 **SYS_ADMIN / DEVELOPER** 可见(MVP)。 ### A.8 SDK 轨道(本仓库,I5 硬交付清单) - **Schema**:`schemas/craftlabs-auth-config.schema.json` 等 — 若 BP-10 变更类型触及「平台导出 → Schema → 客户端」,按 [轨道 C §3](../tracks/03-client-sdk.md) bump 规则执行。 - **Java `AuthConfigs`**:与 Schema 同步(表见轨道 C BP-10)。 -- **`examples/`**:与最新字段及环境变量说明一致;CI 与 Schema 校验对齐。 +- `**examples/**`:与最新字段及环境变量说明一致;CI 与 Schema 校验对齐。 - **文档**:明确 **SDK 版本线 ≠ 平台 Fat JAR 版本**;引用 BPM **BP-10** 与产品 M6-F01/F02 口径。 - **不做**:Native 与 Webhook 运行时耦合;平台不嵌入 JNI。 @@ -167,29 +187,32 @@ Content-Type: application/json ## 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) | -| **Runbook** | [`services/RUNBOOK.md`](../../../services/RUNBOOK.md):**内部 token 轮换**、Webhook→平台连通性检查、DB 迁移顺序(`flyway_platform_api` / `flyway_webhook`)。 | RUNBOOK **§10** | + +| 主题 | 内容要点 | 执行文档 | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| **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) | +| **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) | -| **实现审核** | I1~I6 对照设计与三轨道文档 | [I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md) | +| **实现审核** | I1~I6 对照设计与三轨道文档 | [I6_IMPLEMENTATION_REVIEW.md](./I6_IMPLEMENTATION_REVIEW.md) | + --- ## Part C — 实现顺序与 MVP 切割 -1. **平台 DB(Flyway `V5+`)**:`platform_callback_inbox` + M6 两表 + 必要索引与外键;种子数据(环境/产品线)可选。 -2. **平台内部 API**:`POST /internal/v1/callback-events` + 幂等与 `schemaVersion` 校验 + **`AuditService` 钩子**(状态/关联变更)。 -3. **平台公开 API**:`GET/PATCH` callback-inbox、M6 只读 GET;**统一异常**经 `ApiExceptionHandler`。 -4. **OpenAPI 快照** 与契约测试更新。 -5. **Webhook**:在收据落库后增加 **转发客户端**(重试、观测头);配置项:`PLATFORM_INTERNAL_BASE_URL`、`PLATFORM_INTERNAL_TOKEN`。 -6. **集成测试**:Testcontainers + 双模块或 Compose(与轨道 A **I5+ `cross-service-it`** 建议一致)。 -7. **前端**:路由与表格/详情/脱敏展示;联调 staging。 -8. **SDK / Schema / examples**:按 BP-10 终版对齐并过 CI。 +1. **平台 DB(Flyway `V5+`)**:`platform_callback_inbox` + M6 两表 + 必要索引与外键;种子数据(环境/产品线)可选。 +2. **平台内部 API**:`POST /internal/v1/callback-events` + 幂等与 `schemaVersion` 校验 + `**AuditService` 钩子**(状态/关联变更)。 +3. **平台公开 API**:`GET/PATCH` callback-inbox、M6 只读 GET;**统一异常**经 `ApiExceptionHandler`。 +4. **OpenAPI 快照** 与契约测试更新。 +5. **Webhook**:在收据落库后增加 **转发客户端**(重试、观测头);配置项:`PLATFORM_INTERNAL_BASE_URL`、`PLATFORM_INTERNAL_TOKEN`。 +6. **集成测试**:Testcontainers + 双模块或 Compose(与轨道 A **I5+ `cross-service-it`** 建议一致)。 +7. **前端**:路由与表格/详情/脱敏展示;联调 staging。 +8. **SDK / Schema / examples**:按 BP-10 终版对齐并过 CI。 **MVP 明确推迟(若全量 M5/M6 过大)** + | 推迟项 | 说明 | | ---------- | ------------------------------------------ | | M6-F03~F09 | 比特 ID 映射、特征映射、模板库、发布记录、影响分析 | @@ -197,10 +220,12 @@ Content-Type: application/json | M5-F10 | 模拟投递 UI(可用 curl/Postman 代替) | | MQ 投递 | 保留 HTTP MVP;MQ + 消费者为 ADR 备选(轨道 A 已列 B 方案) | + --- ## Part D — 可追溯性(设计章节 → 产品功能点) + | 设计章节 | 产品模块 / 功能点(适用处) | | ------------------------------- | ------------------------------ | | A.1 幂等与 schemaVersion | M5 运营基础;BP-06 | @@ -213,12 +238,16 @@ Content-Type: application/json | A.8 SDK / Schema | **BP-10**;**M6** 配置治理(文档与校验链) | | Part B I6 | BP-01~06、11 UAT;M11 安全与运维 | + --- ## 修订记录 + | 日期 | 说明 | | ---------- | ----------------------------------------------------------------- | | 2026-04-06 | 初版:I5/I6 架构设计,对齐并行索引、三轨道文档与产品 M5/M6 P0。 | | 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 扫描入口。 | + + diff --git a/docs/engineering/iterations/I6_IMPLEMENTATION_REVIEW.md b/docs/engineering/iterations/I6_IMPLEMENTATION_REVIEW.md index c3edbd0..866eea2 100644 --- a/docs/engineering/iterations/I6_IMPLEMENTATION_REVIEW.md +++ b/docs/engineering/iterations/I6_IMPLEMENTATION_REVIEW.md @@ -88,3 +88,4 @@ | 日期 | 说明 | |------|------| | 2026-04-06 | 初版:I6 架构师审核,对照 I5_I6_DESIGN 与三轨道文档。 | +| 2026-04-06 | I6 表:补充 CI 依赖/CVE 与 Dependabot。 | diff --git a/docs/engineering/iterations/I7_DESIGN.md b/docs/engineering/iterations/I7_DESIGN.md new file mode 100644 index 0000000..702f4e8 --- /dev/null +++ b/docs/engineering/iterations/I7_DESIGN.md @@ -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) 复盘。 | diff --git a/docs/engineering/iterations/I7_IMPLEMENTATION_REVIEW.md b/docs/engineering/iterations/I7_IMPLEMENTATION_REVIEW.md new file mode 100644 index 0000000..6f434d3 --- /dev/null +++ b/docs/engineering/iterations/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 闭环复盘。 | diff --git a/docs/engineering/tracks/02-frontend-platform-ui.md b/docs/engineering/tracks/02-frontend-platform-ui.md index ff9b3c6..1f55ce8 100644 --- a/docs/engineering/tracks/02-frontend-platform-ui.md +++ b/docs/engineering/tracks/02-frontend-platform-ui.md @@ -29,6 +29,7 @@ | **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 | | **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) | --- diff --git a/services/RUNBOOK.md b/services/RUNBOOK.md index c54829e..8209697 100644 --- a/services/RUNBOOK.md +++ b/services/RUNBOOK.md @@ -132,4 +132,15 @@ curl -sS -o /dev/null -w "%{http_code}\n" \ ### 10.4 Flyway 历史表 - 平台:`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` 与平台侧幂等处理。 diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/PlatformApplication.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/PlatformApplication.java index b94968e..72c888b 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/PlatformApplication.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/PlatformApplication.java @@ -4,8 +4,10 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) +@EnableMethodSecurity @MapperScan("cn.craftlabs.platform.api.persistence") public class PlatformApplication { diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java index 703971c..bfb95f9 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java @@ -1,6 +1,7 @@ package cn.craftlabs.platform.api.auth; import cn.craftlabs.platform.api.security.JwtService; +import cn.craftlabs.platform.api.security.PlatformRoles; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -30,29 +31,42 @@ public class AuthController { String pass = body.getOrDefault("password", ""); if ("admin".equals(user) && "admin".equals(pass)) { String token = - jwtService.createToken(user, "管理员", List.of("SYS_ADMIN")); + jwtService.createToken(user, "管理员", List.of(PlatformRoles.SYS_ADMIN)); return Map.of( "token", token, "tokenType", "Bearer", "roles", - List.of("SYS_ADMIN"), + List.of(PlatformRoles.SYS_ADMIN), "displayName", "管理员"); } 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( "token", token, "tokenType", "Bearer", "roles", - List.of("DEVELOPER"), + List.of(PlatformRoles.DEVELOPER), "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"); } } diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java index 2232a4a..d943c7b 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/callback/CallbackInboxController.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RestController; import java.time.OffsetDateTime; @@ -22,6 +23,7 @@ import java.time.OffsetDateTime; @RestController @RequestMapping("/api/v1/callback-inbox") @Validated +@PreAuthorize("hasAnyRole('OPS','SYS_ADMIN')") public class CallbackInboxController { private final CallbackInboxService callbackInboxService; diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/integration/IntegrationCatalogController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/integration/IntegrationCatalogController.java index 81268df..40990e3 100644 --- a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/integration/IntegrationCatalogController.java +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/integration/IntegrationCatalogController.java @@ -11,11 +11,13 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1/integration") @Validated +@PreAuthorize("hasAnyRole('OPS','SYS_ADMIN','DEVELOPER')") public class IntegrationCatalogController { private final IntegrationCatalogService integrationCatalogService; diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/PlatformRoles.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/PlatformRoles.java new file mode 100644 index 0000000..a420895 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/PlatformRoles.java @@ -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() {} +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/auth/AuthControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/auth/AuthControllerTest.java index 8519c96..92ce2e7 100644 --- a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/auth/AuthControllerTest.java +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/auth/AuthControllerTest.java @@ -28,6 +28,17 @@ class AuthControllerTest { .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 void loginFail() throws Exception { mockMvc.perform( diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java index 95df590..69dea2d 100644 --- a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/callback/CallbackInboxControllerTest.java @@ -108,6 +108,17 @@ class CallbackInboxControllerTest { .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 { ObjectNode root = objectMapper.createObjectNode(); root.put("schemaVersion", "1.0"); diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/support/JwtTestSupport.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/support/JwtTestSupport.java index 57cf67e..cd66291 100644 --- a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/support/JwtTestSupport.java +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/support/JwtTestSupport.java @@ -12,11 +12,21 @@ public final class JwtTestSupport { private JwtTestSupport() {} 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 = mockMvc.perform( post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"admin\",\"password\":\"admin\"}")) + .content( + "{\"username\":\"" + + username + + "\",\"password\":\"" + + password + + "\"}")) .andReturn(); String body = login.getResponse().getContentAsString(); return objectMapper.readTree(body).get("token").asText(); diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackIngestController.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackIngestController.java index 2ce67e0..85755fb 100644 --- a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackIngestController.java +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackIngestController.java @@ -26,15 +26,15 @@ public class CallbackIngestController { public static final String HEADER_TOKEN = "x-bitanswer-token"; private final CallbackReceiptService receiptService; - private final PlatformCallbackForwarder platformCallbackForwarder; + private final PlatformDeliveryService platformDeliveryService; @Value("${craftlabs.webhook.expected-token:}") private String expectedToken; public CallbackIngestController( - CallbackReceiptService receiptService, PlatformCallbackForwarder platformCallbackForwarder) { + CallbackReceiptService receiptService, PlatformDeliveryService platformDeliveryService) { this.receiptService = receiptService; - this.platformCallbackForwarder = platformCallbackForwarder; + this.platformDeliveryService = platformDeliveryService; } @PostMapping("/webhook/bitanswer/callback") @@ -54,8 +54,7 @@ public class CallbackIngestController { int bytes = rawBody != null ? rawBody.length() : 0; CallbackReceiptService.ReceiptOutcome outcome = receiptService.recordReceipt(idempotencyKey, bytes); if (outcome.type() == CallbackReceiptService.OutcomeType.INSERTED && outcome.receiptId() != null) { - platformCallbackForwarder.forwardAfterReceipt( - servletRequest, rawBody, idempotencyKey, outcome.receiptId()); + platformDeliveryService.enqueueAfterReceipt(servletRequest, rawBody, idempotencyKey, outcome.receiptId()); } log.info( diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlannedPlatformDelivery.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlannedPlatformDelivery.java new file mode 100644 index 0000000..febfa1a --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlannedPlatformDelivery.java @@ -0,0 +1,6 @@ +package cn.craftlabs.platform.webhook; + +/** + * 发往 {@code POST /internal/v1/callback-events} 的一次投递计划。 + */ +public record PlannedPlatformDelivery(String requestBodyJson, String idempotencyHeader, String traceHeadersJson) {} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackForwarder.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackForwarder.java index 2fa0ae9..0890a2b 100644 --- a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackForwarder.java +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackForwarder.java @@ -1,9 +1,6 @@ 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.beans.factory.annotation.Value; @@ -17,17 +14,15 @@ import org.springframework.web.client.RestClientException; import java.util.function.Consumer; /** - * 收据持久化后同步投递至 delivery-platform-api(MVP:短超时 + 有限重试)。 + * 向 {@code delivery-platform-api} 发送单次 HTTP 投递(重试由 {@link PlatformDeliveryService} 调度)。 */ @Service public class PlatformCallbackForwarder { 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 ObjectMapper objectMapper; @Value("${craftlabs.platform.internal.base-url:}") private String baseUrl; @@ -40,132 +35,46 @@ public class PlatformCallbackForwarder { this.restClient = RestClient.create(); } - public void forwardAfterReceipt( - HttpServletRequest request, - String rawBody, - String idempotencyKey, - long webhookReceiptId) { + /** + * 单次 POST;成功无声返回;失败抛 {@link RestClientException} 由调用方记重试/DEAD。 + */ + public void postOnce(String requestBodyJson, String idempotencyHeader, String traceHeadersJson) + throws RestClientException { 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 idemHeader = StringUtils.hasText(idempotencyKey) ? idempotencyKey.trim() : externalMessageId.trim(); - - for (int attempt = 0; attempt < 3; attempt++) { - try { - restClient - .post() - .uri(url) - .header("X-Platform-Internal-Token", internalToken) - .header("Idempotency-Key", idemHeader) - .contentType(MediaType.APPLICATION_JSON) - .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; - } - } - } - } + restClient + .post() + .uri(url) + .header("X-Platform-Internal-Token", internalToken) + .header("Idempotency-Key", idempotencyHeader) + .contentType(MediaType.APPLICATION_JSON) + .headers(applyTrace(traceHeadersJson)) + .body(requestBodyJson) + .retrieve() + .toBodilessEntity(); } - private static Consumer copyTraceHeaders(HttpServletRequest request) { + private Consumer applyTrace(String traceHeadersJson) { return headers -> { - String tp = request.getHeader("traceparent"); - if (StringUtils.hasText(tp)) { - headers.add("traceparent", tp); + if (!StringUtils.hasText(traceHeadersJson)) { + return; } - String rid = request.getHeader("X-Request-Id"); - if (StringUtils.hasText(rid)) { - headers.add("X-Request-Id", rid); + try { + var node = objectMapper.readTree(traceHeadersJson); + 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; - } } diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackRequestPlanner.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackRequestPlanner.java new file mode 100644 index 0000000..c8c54cc --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformCallbackRequestPlanner.java @@ -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 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 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; + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryScheduler.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryScheduler.java new file mode 100644 index 0000000..37dfcdc --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryScheduler.java @@ -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()); + } + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java new file mode 100644 index 0000000..ba2517e --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/PlatformDeliveryService.java @@ -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 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()); + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookApplication.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookApplication.java index bc1acf4..68f5dfa 100644 --- a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookApplication.java +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookApplication.java @@ -3,8 +3,10 @@ package cn.craftlabs.platform.webhook; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling @MapperScan("cn.craftlabs.platform.webhook.persistence") public class WebhookApplication { diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDelivery.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDelivery.java new file mode 100644 index 0000000..4d804b0 --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDelivery.java @@ -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; + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDeliveryMapper.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDeliveryMapper.java new file mode 100644 index 0000000..501e132 --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookPlatformDeliveryMapper.java @@ -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 { + + @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 selectPendingDue(@Param("limit") int limit); +} diff --git a/services/license-webhook-ingress/src/main/resources/application.yml b/services/license-webhook-ingress/src/main/resources/application.yml index f417d7d..ad784fa 100644 --- a/services/license-webhook-ingress/src/main/resources/application.yml +++ b/services/license-webhook-ingress/src/main/resources/application.yml @@ -34,3 +34,8 @@ craftlabs: internal: base-url: ${PLATFORM_INTERNAL_BASE_URL:} 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} diff --git a/services/license-webhook-ingress/src/main/resources/db/migration/V2__webhook_platform_delivery.sql b/services/license-webhook-ingress/src/main/resources/db/migration/V2__webhook_platform_delivery.sql new file mode 100644 index 0000000..f80f420 --- /dev/null +++ b/services/license-webhook-ingress/src/main/resources/db/migration/V2__webhook_platform_delivery.sql @@ -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); diff --git a/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/PlatformDeliveryEnqueueTest.java b/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/PlatformDeliveryEnqueueTest.java new file mode 100644 index 0000000..b003bc3 --- /dev/null +++ b/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/PlatformDeliveryEnqueueTest.java @@ -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); + } +} diff --git a/services/license-webhook-ingress/src/test/resources/application.yml b/services/license-webhook-ingress/src/test/resources/application.yml index ed198f7..1409f52 100644 --- a/services/license-webhook-ingress/src/test/resources/application.yml +++ b/services/license-webhook-ingress/src/test/resources/application.yml @@ -18,3 +18,9 @@ mybatis-plus: craftlabs: webhook: expected-token: test-secret + platform: + internal: + base-url: http://127.0.0.1:65509 + token: unit-test-internal-token + delivery: + scheduler-enabled: false diff --git a/services/license-webhook-ingress/src/test/resources/schema-webhook-test.sql b/services/license-webhook-ingress/src/test/resources/schema-webhook-test.sql index 3bd3954..fa7dc02 100644 --- a/services/license-webhook-ingress/src/test/resources/schema-webhook-test.sql +++ b/services/license-webhook-ingress/src/test/resources/schema-webhook-test.sql @@ -6,3 +6,18 @@ CREATE TABLE IF NOT EXISTS webhook_callback_receipt ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, 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) +); diff --git a/web/delivery-platform-ui/src/layout/MainLayout.vue b/web/delivery-platform-ui/src/layout/MainLayout.vue index f48a719..e7b3cd2 100644 --- a/web/delivery-platform-ui/src/layout/MainLayout.vue +++ b/web/delivery-platform-ui/src/layout/MainLayout.vue @@ -6,28 +6,28 @@ 首页 - + 客户管理 - + 项目管理 - + 合同管理 - + 交付管理 - + 许可 SN - + Callback 收件箱 - + 集成环境 - + 产品线 diff --git a/web/delivery-platform-ui/src/router/index.js b/web/delivery-platform-ui/src/router/index.js index f947211..6ef732e 100644 --- a/web/delivery-platform-ui/src/router/index.js +++ b/web/delivery-platform-ui/src/router/index.js @@ -12,7 +12,7 @@ const routes = [ path: "", name: "home", component: () => import("../views/HomeView.vue"), - meta: { roles: ["SYS_ADMIN", "DEVELOPER"] }, + meta: { roles: ["SYS_ADMIN", "DEVELOPER", "OPS"] }, }, { path: "customers", @@ -66,25 +66,25 @@ const routes = [ path: "integration/environments", name: "integration-environments", component: () => import("../views/IntegrationEnvironmentsView.vue"), - meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "集成环境" }, + meta: { roles: ["SYS_ADMIN", "DEVELOPER", "OPS"], title: "集成环境" }, }, { path: "integration/product-lines", name: "integration-product-lines", component: () => import("../views/IntegrationProductLinesView.vue"), - meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "产品线" }, + meta: { roles: ["SYS_ADMIN", "DEVELOPER", "OPS"], title: "产品线" }, }, { path: "callbacks/:id", name: "callback-inbox-detail", component: () => import("../views/CallbackInboxDetailView.vue"), - meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "Callback 详情" }, + meta: { roles: ["SYS_ADMIN", "OPS"], title: "Callback 详情" }, }, { path: "callbacks", name: "callback-inbox", component: () => import("../views/CallbackInboxView.vue"), - meta: { roles: ["SYS_ADMIN", "DEVELOPER"], title: "Callback 收件箱" }, + meta: { roles: ["SYS_ADMIN", "OPS"], title: "Callback 收件箱" }, }, { path: "contracts/new", diff --git a/web/delivery-platform-ui/src/stores/auth.js b/web/delivery-platform-ui/src/stores/auth.js index dc32ebe..03a7f70 100644 --- a/web/delivery-platform-ui/src/stores/auth.js +++ b/web/delivery-platform-ui/src/stores/auth.js @@ -9,6 +9,15 @@ export const useAuthStore = defineStore("auth", { displayName: "", roles: [], }), + getters: { + hasAnyRole: (state) => { + return (roleList) => { + const need = roleList || []; + const have = state.roles || []; + return need.some((r) => have.includes(r)); + }; + }, + }, actions: { async login(username, password) { const { data } = await axios.post("/api/v1/auth/login", { username, password }); diff --git a/web/delivery-platform-ui/src/views/HomeView.vue b/web/delivery-platform-ui/src/views/HomeView.vue index dd49787..cad1ac3 100644 --- a/web/delivery-platform-ui/src/views/HomeView.vue +++ b/web/delivery-platform-ui/src/views/HomeView.vue @@ -2,14 +2,14 @@

用户:{{ auth.displayName }},角色:{{ auth.roles.join(", ") || "—" }}

@@ -23,7 +23,7 @@