From 3f577b34d543c196a32c0daba056110e15876ff6 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 6 Apr 2026 21:04:56 +0800 Subject: [PATCH] feat(platform): I1 bootstrap, I2 M1 APIs, OpenAPI SSOT, and CI guards Deliver dual Spring Boot services (platform API + webhook ingress), JWT auth, Flyway with isolated history tables, customer/project/dictionary endpoints, OpenAPI snapshot under contracts/, RUNBOOK, and CI that runs on services/web/contracts paths plus enforcer + dependency tree ban on craftlabs-auth-bitanswer. Made-with: Cursor --- .github/workflows/ci-platform.yml | 52 ++ contracts/README.md | 27 + contracts/openapi/delivery-platform-api.json | 570 ++++++++++++++++++ services/README.md | 51 ++ services/RUNBOOK.md | 97 +++ services/delivery-platform-api/pom.xml | 106 ++++ .../platform/api/PlatformApplication.java | 15 + .../platform/api/auth/AuthController.java | 58 ++ .../api/config/MybatisPlusConfig.java | 19 + .../platform/api/config/OpenApiConfig.java | 32 + .../platform/api/config/SecurityConfig.java | 48 ++ .../api/customer/CustomerController.java | 67 ++ .../api/dictionary/DictionaryController.java | 26 + .../platform/api/domain/CustomerStatus.java | 9 + .../customer/PlatformCustomer.java | 76 +++ .../customer/PlatformCustomerMapper.java | 7 + .../dictionary/PlatformDictionary.java | 75 +++ .../dictionary/PlatformDictionaryMapper.java | 7 + .../persistence/project/PlatformProject.java | 76 +++ .../project/PlatformProjectMapper.java | 7 + .../platform/api/ping/PingController.java | 17 + .../api/project/ProjectController.java | 65 ++ .../api/security/JwtAuthenticationFilter.java | 62 ++ .../platform/api/security/JwtService.java | 47 ++ .../platform/api/service/CustomerService.java | 128 ++++ .../api/service/DictionaryService.java | 40 ++ .../platform/api/service/ProjectService.java | 111 ++++ .../platform/api/web/dto/CustomerRequest.java | 41 ++ .../api/web/dto/CustomerResponse.java | 61 ++ .../api/web/dto/DictionaryItemResponse.java | 40 ++ .../platform/api/web/dto/PageResponse.java | 55 ++ .../platform/api/web/dto/ProjectRequest.java | 42 ++ .../platform/api/web/dto/ProjectResponse.java | 61 ++ .../src/main/resources/application.yml | 36 ++ .../db/migration/V1__platform_core_tables.sql | 30 + .../V2__seed_project_phase_dictionary.sql | 5 + .../platform/api/auth/AuthControllerTest.java | 39 ++ .../contract/OpenApiContractSnapshotTest.java | 76 +++ .../api/customer/CustomerControllerTest.java | 91 +++ .../dictionary/DictionaryControllerTest.java | 43 ++ .../platform/api/ping/PingWithJwtTest.java | 51 ++ .../api/project/ProjectControllerTest.java | 95 +++ .../platform/api/support/JwtTestSupport.java | 24 + .../src/test/resources/application.yml | 14 + services/docker-compose.yml | 10 + services/license-webhook-ingress/pom.xml | 71 +++ .../webhook/CallbackIngestController.java | 59 ++ .../webhook/CallbackReceiptService.java | 37 ++ .../platform/webhook/WebhookApplication.java | 14 + .../persistence/WebhookCallbackReceipt.java | 52 ++ .../WebhookCallbackReceiptMapper.java | 7 + .../src/main/resources/application.yml | 32 + .../V1__webhook_callback_receipt.sql | 8 + .../webhook/CallbackIngestControllerTest.java | 53 ++ .../src/test/resources/application.yml | 20 + .../test/resources/schema-webhook-test.sql | 8 + services/pom.xml | 100 +++ 57 files changed, 3170 insertions(+) create mode 100644 .github/workflows/ci-platform.yml create mode 100644 contracts/README.md create mode 100644 contracts/openapi/delivery-platform-api.json create mode 100644 services/README.md create mode 100644 services/RUNBOOK.md create mode 100644 services/delivery-platform-api/pom.xml create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/PlatformApplication.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/MybatisPlusConfig.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/OpenApiConfig.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/customer/CustomerController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/dictionary/DictionaryController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/CustomerStatus.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomer.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomerMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionary.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionaryMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProject.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectMapper.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/ping/PingController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtAuthenticationFilter.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CustomerService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DictionaryService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DictionaryItemResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/PageResponse.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectRequest.java create mode 100644 services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectResponse.java create mode 100644 services/delivery-platform-api/src/main/resources/application.yml create mode 100644 services/delivery-platform-api/src/main/resources/db/migration/V1__platform_core_tables.sql create mode 100644 services/delivery-platform-api/src/main/resources/db/migration/V2__seed_project_phase_dictionary.sql create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/auth/AuthControllerTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contract/OpenApiContractSnapshotTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/customer/CustomerControllerTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/dictionary/DictionaryControllerTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/ping/PingWithJwtTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/project/ProjectControllerTest.java create mode 100644 services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/support/JwtTestSupport.java create mode 100644 services/delivery-platform-api/src/test/resources/application.yml create mode 100644 services/docker-compose.yml create mode 100644 services/license-webhook-ingress/pom.xml create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackIngestController.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackReceiptService.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookApplication.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceipt.java create mode 100644 services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceiptMapper.java create mode 100644 services/license-webhook-ingress/src/main/resources/application.yml create mode 100644 services/license-webhook-ingress/src/main/resources/db/migration/V1__webhook_callback_receipt.sql create mode 100644 services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/CallbackIngestControllerTest.java create mode 100644 services/license-webhook-ingress/src/test/resources/application.yml create mode 100644 services/license-webhook-ingress/src/test/resources/schema-webhook-test.sql create mode 100644 services/pom.xml diff --git a/.github/workflows/ci-platform.yml b/.github/workflows/ci-platform.yml new file mode 100644 index 0000000..693480c --- /dev/null +++ b/.github/workflows/ci-platform.yml @@ -0,0 +1,52 @@ +name: ci-platform + +on: + push: + branches: [main, master] + paths: + - "services/**" + - "web/**" + - "contracts/**" + - ".github/workflows/ci-platform.yml" + pull_request: + branches: [main, master] + paths: + - "services/**" + - "web/**" + - "contracts/**" + - ".github/workflows/ci-platform.yml" + +jobs: + maven-services: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + - name: Forbid craftlabs-auth-bitanswer in platform tree + run: | + set -euo pipefail + TREE=$(mvn -f services/pom.xml -q -DskipTests dependency:tree -pl delivery-platform-api,license-webhook-ingress -am) + if echo "$TREE" | grep -q 'craftlabs-auth-bitanswer'; then + echo "::error::Banned dependency craftlabs-auth-bitanswer found in platform dependency tree" + echo "$TREE" + exit 1 + fi + - name: Maven verify (platform services) + run: mvn -f services/pom.xml -B verify + + web-ui-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: npm install and build + working-directory: web/delivery-platform-ui + run: | + npm install + npm run build diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..e2b6608 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,27 @@ +# 契约(Contracts) + +## OpenAPI — 交付平台 API + +| 文件 | 说明 | +|------|------| +| [`openapi/delivery-platform-api.json`](openapi/delivery-platform-api.json) | **`delivery-platform-api` 的单一事实来源(SSOT)**;与运行时 `/v3/api-docs` 对齐。 | + +### 更新快照(维护者) + +在仓库根目录或 `services/delivery-platform-api` 下执行(需 JDK 17): + +```bash +export JAVA_HOME=… # JDK 17+ +cd services/delivery-platform-api +UPDATE_OPENAPI=1 mvn -q test -Dtest=OpenApiContractSnapshotTest +``` + +提交前请 **审阅 diff**:破坏性变更需 bump 版本说明、同步前端与集成方。 + +### CI 校验 + +默认 `mvn verify` 会运行 `OpenApiContractSnapshotTest`:**运行时生成的 OpenAPI 与快照须一致**。若仅改实现未改契约却导致文档变化,应更新快照并写在 PR 说明中。 + +### 与前端 + +`web/delivery-platform-ui` 的 axios 路径应与 OpenAPI `paths` 一致;可选后续接入 OpenAPI Generator 生成 TS 类型(非必选)。 diff --git a/contracts/openapi/delivery-platform-api.json b/contracts/openapi/delivery-platform-api.json new file mode 100644 index 0000000..8c55d2d --- /dev/null +++ b/contracts/openapi/delivery-platform-api.json @@ -0,0 +1,570 @@ +{ + "openapi" : "3.1.0", + "info" : { + "title" : "CraftLabs 交付管理平台 API", + "description" : "I1+:JWT 认证;迭代扩展 M1~M11", + "version" : "0.1.0-SNAPSHOT" + }, + "servers" : [ { + "url" : "http://localhost", + "description" : "Generated server url" + } ], + "paths" : { + "/api/v1/projects/{id}" : { + "get" : { + "tags" : [ "project-controller" ], + "operationId" : "get", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ProjectResponse" + } + } + } + } + } + }, + "put" : { + "tags" : [ "project-controller" ], + "operationId" : "update", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ProjectRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ProjectResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "project-controller" ], + "operationId" : "delete", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "No Content" + } + } + } + }, + "/api/v1/customers/{id}" : { + "get" : { + "tags" : [ "customer-controller" ], + "operationId" : "get_1", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CustomerResponse" + } + } + } + } + } + }, + "put" : { + "tags" : [ "customer-controller" ], + "operationId" : "update_1", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CustomerRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CustomerResponse" + } + } + } + } + } + }, + "delete" : { + "tags" : [ "customer-controller" ], + "operationId" : "delete_1", + "parameters" : [ { + "name" : "id", + "in" : "path", + "required" : true, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "204" : { + "description" : "No Content" + } + } + } + }, + "/api/v1/projects" : { + "get" : { + "tags" : [ "project-controller" ], + "operationId" : "list", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 0, + "minimum" : 0 + } + }, { + "name" : "size", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 20, + "maximum" : 200, + "minimum" : 1 + } + }, { + "name" : "customerId", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int64" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageResponseProjectResponse" + } + } + } + } + } + }, + "post" : { + "tags" : [ "project-controller" ], + "operationId" : "create", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ProjectRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/ProjectResponse" + } + } + } + } + } + } + }, + "/api/v1/customers" : { + "get" : { + "tags" : [ "customer-controller" ], + "operationId" : "list_1", + "parameters" : [ { + "name" : "page", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 0, + "minimum" : 0 + } + }, { + "name" : "size", + "in" : "query", + "required" : false, + "schema" : { + "type" : "integer", + "format" : "int32", + "default" : 20, + "maximum" : 200, + "minimum" : 1 + } + }, { + "name" : "keyword", + "in" : "query", + "required" : false, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageResponseCustomerResponse" + } + } + } + } + } + }, + "post" : { + "tags" : [ "customer-controller" ], + "operationId" : "create_1", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CustomerRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "Created", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/CustomerResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/login" : { + "post" : { + "tags" : [ "auth-controller" ], + "operationId" : "login", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + } + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "object", + "additionalProperties" : { } + } + } + } + } + } + } + }, + "/api/v1/ping" : { + "get" : { + "tags" : [ "ping-controller" ], + "operationId" : "ping", + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "object", + "additionalProperties" : { + "type" : "string" + } + } + } + } + } + } + } + }, + "/api/v1/dictionaries/{type}" : { + "get" : { + "tags" : [ "dictionary-controller" ], + "operationId" : "listByType", + "parameters" : [ { + "name" : "type", + "in" : "path", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "description" : "OK", + "content" : { + "*/*" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/DictionaryItemResponse" + } + } + } + } + } + } + } + } + }, + "components" : { + "schemas" : { + "ProjectRequest" : { + "type" : "object", + "properties" : { + "customerId" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string", + "maxLength" : 256, + "minLength" : 0 + }, + "phase" : { + "type" : "string", + "maxLength" : 64, + "minLength" : 0 + } + }, + "required" : [ "customerId", "name" ] + }, + "ProjectResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "customerId" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + }, + "phase" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "CustomerRequest" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "maxLength" : 256, + "minLength" : 0 + }, + "creditCode" : { + "type" : "string", + "maxLength" : 64, + "minLength" : 0 + }, + "status" : { + "type" : "string", + "maxLength" : 32, + "minLength" : 0 + } + }, + "required" : [ "name" ] + }, + "CustomerResponse" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "integer", + "format" : "int64" + }, + "name" : { + "type" : "string" + }, + "creditCode" : { + "type" : "string" + }, + "status" : { + "type" : "string" + }, + "createdAt" : { + "type" : "string", + "format" : "date-time" + }, + "updatedAt" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "PageResponseProjectResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/ProjectResponse" + } + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "number" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "DictionaryItemResponse" : { + "type" : "object", + "properties" : { + "dictCode" : { + "type" : "string" + }, + "dictLabel" : { + "type" : "string" + }, + "sortOrder" : { + "type" : "integer", + "format" : "int32" + } + } + }, + "PageResponseCustomerResponse" : { + "type" : "object", + "properties" : { + "content" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/CustomerResponse" + } + }, + "totalElements" : { + "type" : "integer", + "format" : "int64" + }, + "number" : { + "type" : "integer", + "format" : "int32" + }, + "size" : { + "type" : "integer", + "format" : "int32" + } + } + } + }, + "securitySchemes" : { + "bearer-jwt" : { + "type" : "http", + "name" : "bearer-jwt", + "scheme" : "bearer", + "bearerFormat" : "JWT" + } + } + } +} \ No newline at end of file diff --git a/services/README.md b/services/README.md new file mode 100644 index 0000000..fc05a38 --- /dev/null +++ b/services/README.md @@ -0,0 +1,51 @@ +# 平台后端服务(I1 骨架) + +与 **`java/craftlabs-auth-*` SDK 分构建线**,坐标 `cn.craftlabs.platform`,**不依赖** `craftlabs-auth-bitanswer`。 + +- **部署与运维**:[RUNBOOK.md](RUNBOOK.md) +- **OpenAPI 契约(SSOT)**:[../contracts/README.md](../contracts/README.md) +- **误依赖门禁**:`maven-enforcer-plugin`(`validate`)+ CI `dependency:tree` 扫描 `craftlabs-auth-bitanswer` + +| 模块 | 端口 | 说明 | +|------|------|------| +| `license-webhook-ingress` | 8081 | `POST /webhook/bitanswer/callback`,头 `x-bitanswer-token`(可选 `CRAFTLABS_WEBHOOK_EXPECTED_TOKEN`);**Flyway** 建表 `webhook_callback_receipt`,**Idempotency-Key** 落库幂等 | +| `delivery-platform-api` | 8080 | **M1**:`GET|POST /api/v1/customers`、`GET|PUT|DELETE /api/v1/customers/{id}`(DELETE 软删 → `INACTIVE`);`GET|POST /api/v1/projects`、`GET|PUT|DELETE /api/v1/projects/{id}`(DELETE 物理删);`GET /api/v1/dictionaries/{type}`;另 `POST /api/v1/auth/login`、`GET /api/v1/ping`、JWT `Authorization: Bearer`、**OpenAPI** `/swagger-ui.html`;**Flyway** 表 `flyway_platform_api` | + +生产须设置 **`PLATFORM_JWT_SECRET`**(≥32 字符)。 + +## I2 / M1(客户与项目) + +| 路径 | 说明 | +|------|------| +| `/api/v1/customers` | 客户分页 CRUD(删除为软删 `INACTIVE`) | +| `/api/v1/projects` | 项目分页 CRUD(删除为物理删除) | +| `/api/v1/dictionaries/{type}` | 字典项,如 `PROJECT_PHASE` | + +Flyway 历史表:`delivery-platform-api` → **`flyway_platform_api`**;`license-webhook-ingress` → **`flyway_webhook`**。 + +## 构建 Fat JAR + +```bash +mvn -f services/pom.xml -pl license-webhook-ingress -am clean package +mvn -f services/pom.xml -pl delivery-platform-api -am clean package +``` + +产物:`*/target/*.jar`(spring-boot-maven-plugin repackage)。 + +## Spring Boot 版本 + +父 POM 当前为 **3.4.5**(易解析);产品目标 **4.0.\*** 时升级 `services/pom.xml` 中 `spring-boot.version` 并全量回归。 + +## 数据层(架构约定) + +| 项 | 说明 | +|----|------| +| **数据库** | **PostgreSQL 15** | +| **ORM** | **MyBatis-Plus**(`mybatis-plus-spring-boot3-starter`,版本见父 POM `mybatis-plus.version`) | + +本地起库:`docker compose -f services/docker-compose.yml up -d`,默认库名 `craftlabs_platform`、用户/密码 `craftlabs`/`craftlabs`,与 `delivery-platform-api` 的 `application.yml` 默认值一致。 +Maven 单测使用 **H2(PostgreSQL 兼容模式)**,不强制本机安装 PostgreSQL。 + +## 环境变量(数据源,可选) + +覆盖默认 JDBC 时使用:`SPRING_DATASOURCE_URL`、`SPRING_DATASOURCE_USERNAME`、`SPRING_DATASOURCE_PASSWORD`。 diff --git a/services/RUNBOOK.md b/services/RUNBOOK.md new file mode 100644 index 0000000..b579689 --- /dev/null +++ b/services/RUNBOOK.md @@ -0,0 +1,97 @@ +# 平台后端部署 Runbook(单机 Fat JAR) + +面向 **广州创飞** 小团队:**单/双进程 `java -jar`** + **PostgreSQL 15**,无 K8s 前提。 + +## 1. 组件与端口 + +| 进程 | JAR | 默认端口 | 说明 | +|------|-----|----------|------| +| 交付平台 API | `delivery-platform-api-*-SNAPSHOT.jar` | **8080** | JWT、M1+ 领域 API、Flyway 表 **`flyway_platform_api`** | +| License Webhook | `license-webhook-ingress-*-SNAPSHOT.jar` | **8081** | 比特 Callback;Flyway 表 **`flyway_webhook`** | + +两进程可部署在 **同一主机** 或 **两台主机**;共用同一数据库实例时,须使用 **不同 Flyway 历史表**(已在 `application.yml` 配置)。 + +## 2. 构建产物 + +```bash +mvn -f services/pom.xml -pl delivery-platform-api -am clean package +mvn -f services/pom.xml -pl license-webhook-ingress -am clean package +``` + +产物:`services/*/target/*.jar`(Spring Boot repackage)。 + +## 3. PostgreSQL 15 + +本地/联调示例: + +```bash +docker compose -f services/docker-compose.yml up -d +``` + +默认库 `craftlabs_platform`、用户/密码 `craftlabs`/`craftlabs`(**生产须替换**)。 + +**网络**:数据库 **不对公网**;仅应用主机或 VPC 内可达。 + +## 4. 环境变量(摘要) + +### 共用数据源(两服务均可) + +| 变量 | 说明 | +|------|------| +| `SPRING_DATASOURCE_URL` | 例:`jdbc:postgresql://db:5432/craftlabs_platform` | +| `SPRING_DATASOURCE_USERNAME` | 数据库用户 | +| `SPRING_DATASOURCE_PASSWORD` | 数据库密码 | + +### 平台 API 专有 + +| 变量 | 说明 | +|------|------| +| `PLATFORM_JWT_SECRET` | **必填(生产)**,长度 ≥ **32** 字符;用于签发/校验 JWT | +| `PLATFORM_JWT_EXPIRY_SECONDS` | 可选,默认 `43200`(12h) | + +### Webhook 专有 + +| 变量 | 说明 | +|------|------| +| `CRAFTLABS_WEBHOOK_EXPECTED_TOKEN` | **生产强烈建议设置**;与请求头 `x-bitanswer-token` 一致 | + +## 5. 启动顺序与迁移 + +- **Flyway**:各 JAR **首次启动** 时自动迁移;无强制先后,但 **同一库** 上两应用使用 **不同历史表**,互不覆盖。 +- **建议**:先确认数据库已创建且账号可连,再先后启动 **API** 与 **Webhook**(顺序可互换)。 + +示例(生产请用 systemd/supervisor 等托管): + +```bash +export SPRING_DATASOURCE_URL=jdbc:postgresql://127.0.0.1:5432/craftlabs_platform +export SPRING_DATASOURCE_USERNAME=craftlabs +export SPRING_DATASOURCE_PASSWORD='********' +export PLATFORM_JWT_SECRET='至少32字符的随机密钥' + +java -jar delivery-platform-api-0.1.0-SNAPSHOT.jar + +# 另一终端 +export CRAFTLABS_WEBHOOK_EXPECTED_TOKEN='与比特控制台配置一致' +java -jar license-webhook-ingress-0.1.0-SNAPSHOT.jar +``` + +## 6. 健康检查 + +- API:`GET http://127.0.0.1:8080/actuator/health` +- Webhook:`GET http://127.0.0.1:8081/actuator/health` + +## 7. 契约与前端 + +- OpenAPI 快照:`contracts/openapi/delivery-platform-api.json` +- 运行时文档:`http://:8080/swagger-ui.html`(生产建议 **限制 IP** 或 **关闭**) + +前端静态资源由 **Nginx/Caddy** 托管,`/api` **反代** 至 8080;参见 `web/delivery-platform-ui` README / `vite` 开发代理配置。 + +## 8. 禁止事项(架构) + +- 平台 **classpath 禁止** `craftlabs-auth-bitanswer`(Maven Enforcer + CI 门禁);详见根目录 `contracts/README.md` 与 `services/pom.xml` 说明。 + +## 9. 回滚 + +- 应用:回退上一版 JAR 并重启。 +- 数据库:Flyway **无自动 down**;回滚需 **人工迁移脚本** 或从备份恢复(生产变更前应备份)。 diff --git a/services/delivery-platform-api/pom.xml b/services/delivery-platform-api/pom.xml new file mode 100644 index 0000000..4867b7c --- /dev/null +++ b/services/delivery-platform-api/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + cn.craftlabs.platform + craftlabs-platform-services-parent + 0.1.0-SNAPSHOT + + + delivery-platform-api + Delivery Platform API + 客户商务与交付管理平台 API(I1 登录壳);默认端口 8080。与 SDK 分线,禁止依赖 craftlabs-auth-bitanswer。 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.springframework.boot + spring-boot-starter-security + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 + + + org.springframework.boot + spring-boot-starter-actuator + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + + 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 new file mode 100644 index 0000000..b94968e --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/PlatformApplication.java @@ -0,0 +1,15 @@ +package cn.craftlabs.platform.api; + +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; + +@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) +@MapperScan("cn.craftlabs.platform.api.persistence") +public class PlatformApplication { + + public static void main(String[] args) { + SpringApplication.run(PlatformApplication.class, args); + } +} 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 new file mode 100644 index 0000000..703971c --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/auth/AuthController.java @@ -0,0 +1,58 @@ +package cn.craftlabs.platform.api.auth; + +import cn.craftlabs.platform.api.security.JwtService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +/** + * I1:演示账号签发 JWT(I2 起接用户表与密码哈希)。 + */ +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final JwtService jwtService; + + public AuthController(JwtService jwtService) { + this.jwtService = jwtService; + } + + @PostMapping("/login") + public Map login(@RequestBody Map body) { + String user = body.getOrDefault("username", ""); + String pass = body.getOrDefault("password", ""); + if ("admin".equals(user) && "admin".equals(pass)) { + String token = + jwtService.createToken(user, "管理员", List.of("SYS_ADMIN")); + return Map.of( + "token", + token, + "tokenType", + "Bearer", + "roles", + List.of("SYS_ADMIN"), + "displayName", + "管理员"); + } + if ("dev".equals(user) && "dev".equals(pass)) { + String token = jwtService.createToken(user, "开发账号", List.of("DEVELOPER")); + return Map.of( + "token", + token, + "tokenType", + "Bearer", + "roles", + List.of("DEVELOPER"), + "displayName", + "开发账号"); + } + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials"); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/MybatisPlusConfig.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/MybatisPlusConfig.java new file mode 100644 index 0000000..2a7fdfe --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/MybatisPlusConfig.java @@ -0,0 +1,19 @@ +package cn.craftlabs.platform.api.config; + +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MybatisPlusConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + PaginationInnerInterceptor page = new PaginationInnerInterceptor(); + page.setOverflow(false); + interceptor.addInnerInterceptor(page); + return interceptor; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/OpenApiConfig.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/OpenApiConfig.java new file mode 100644 index 0000000..ca7ff2f --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/OpenApiConfig.java @@ -0,0 +1,32 @@ +package cn.craftlabs.platform.api.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + final String bearer = "bearer-jwt"; + return new OpenAPI() + .info( + new Info() + .title("CraftLabs 交付管理平台 API") + .description("I1+:JWT 认证;迭代扩展 M1~M11") + .version("0.1.0-SNAPSHOT")) + .components( + new Components() + .addSecuritySchemes( + bearer, + new SecurityScheme() + .name(bearer) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java new file mode 100644 index 0000000..7ab3fd6 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/config/SecurityConfig.java @@ -0,0 +1,48 @@ +package cn.craftlabs.platform.api.config; + +import cn.craftlabs.platform.api.security.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * I1:JWT(Bearer)保护业务 API;登录与健康检查、OpenAPI 文档放行。 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) + throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement( + sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/actuator/health", + "/actuator/info", + "/api/v1/auth/login", + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs", + "/v3/api-docs/**") + .permitAll() + .anyRequest() + .authenticated()) + .httpBasic(b -> b.disable()) + .exceptionHandling( + ex -> + ex.authenticationEntryPoint( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/customer/CustomerController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/customer/CustomerController.java new file mode 100644 index 0000000..537272b --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/customer/CustomerController.java @@ -0,0 +1,67 @@ +package cn.craftlabs.platform.api.customer; + +import cn.craftlabs.platform.api.service.CustomerService; +import cn.craftlabs.platform.api.web.dto.CustomerRequest; +import cn.craftlabs.platform.api.web.dto.CustomerResponse; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * 客户 API。{@code DELETE /{id}} 为软删除:将 {@code status} 置为 {@code INACTIVE}(可重复调用)。 + */ +@RestController +@RequestMapping("/api/v1/customers") +@Validated +public class CustomerController { + + private final CustomerService customerService; + + public CustomerController(CustomerService customerService) { + this.customerService = customerService; + } + + @GetMapping + public PageResponse list( + @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size, + @RequestParam(value = "keyword", required = false) String keyword) { + return customerService.page(page, size, keyword); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CustomerResponse create(@Valid @RequestBody CustomerRequest request) { + return customerService.create(request); + } + + @GetMapping("/{id}") + public CustomerResponse get(@PathVariable("id") long id) { + return customerService.getById(id); + } + + @PutMapping("/{id}") + public CustomerResponse update( + @PathVariable("id") long id, @Valid @RequestBody CustomerRequest request) { + return customerService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable("id") long id) { + customerService.delete(id); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/dictionary/DictionaryController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/dictionary/DictionaryController.java new file mode 100644 index 0000000..56a15b6 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/dictionary/DictionaryController.java @@ -0,0 +1,26 @@ +package cn.craftlabs.platform.api.dictionary; + +import cn.craftlabs.platform.api.service.DictionaryService; +import cn.craftlabs.platform.api.web.dto.DictionaryItemResponse; +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.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/dictionaries") +public class DictionaryController { + + private final DictionaryService dictionaryService; + + public DictionaryController(DictionaryService dictionaryService) { + this.dictionaryService = dictionaryService; + } + + @GetMapping("/{type}") + public List listByType(@PathVariable("type") String type) { + return dictionaryService.listEnabledByType(type); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/CustomerStatus.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/CustomerStatus.java new file mode 100644 index 0000000..8fbf0e4 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/domain/CustomerStatus.java @@ -0,0 +1,9 @@ +package cn.craftlabs.platform.api.domain; + +public final class CustomerStatus { + + public static final String ACTIVE = "ACTIVE"; + public static final String INACTIVE = "INACTIVE"; + + private CustomerStatus() {} +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomer.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomer.java new file mode 100644 index 0000000..7bbfe6b --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomer.java @@ -0,0 +1,76 @@ +package cn.craftlabs.platform.api.persistence.customer; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.OffsetDateTime; + +@TableName("platform_customer") +public class PlatformCustomer { + + @TableId(type = IdType.AUTO) + private Long id; + + private String name; + + @TableField("credit_code") + private String creditCode; + + private String status; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCreditCode() { + return creditCode; + } + + public void setCreditCode(String creditCode) { + this.creditCode = creditCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + 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/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomerMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomerMapper.java new file mode 100644 index 0000000..295e459 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/customer/PlatformCustomerMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.customer; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformCustomerMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionary.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionary.java new file mode 100644 index 0000000..2124758 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionary.java @@ -0,0 +1,75 @@ +package cn.craftlabs.platform.api.persistence.dictionary; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +@TableName("platform_dictionary") +public class PlatformDictionary { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("dict_type") + private String dictType; + + @TableField("dict_code") + private String dictCode; + + @TableField("dict_label") + private String dictLabel; + + @TableField("sort_order") + private Integer sortOrder; + + private Boolean enabled; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getDictType() { + return dictType; + } + + public void setDictType(String dictType) { + this.dictType = dictType; + } + + public String getDictCode() { + return dictCode; + } + + public void setDictCode(String dictCode) { + this.dictCode = dictCode; + } + + public String getDictLabel() { + return dictLabel; + } + + public void setDictLabel(String dictLabel) { + this.dictLabel = dictLabel; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionaryMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionaryMapper.java new file mode 100644 index 0000000..d401fe8 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/dictionary/PlatformDictionaryMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.dictionary; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformDictionaryMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProject.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProject.java new file mode 100644 index 0000000..9096a87 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProject.java @@ -0,0 +1,76 @@ +package cn.craftlabs.platform.api.persistence.project; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import java.time.OffsetDateTime; + +@TableName("platform_project") +public class PlatformProject { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("customer_id") + private Long customerId; + + private String name; + + private String phase; + + @TableField("created_at") + private OffsetDateTime createdAt; + + @TableField("updated_at") + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPhase() { + return phase; + } + + public void setPhase(String phase) { + this.phase = phase; + } + + 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/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectMapper.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectMapper.java new file mode 100644 index 0000000..9cccac7 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/persistence/project/PlatformProjectMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.api.persistence.project; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PlatformProjectMapper extends BaseMapper {} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/ping/PingController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/ping/PingController.java new file mode 100644 index 0000000..00e1371 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/ping/PingController.java @@ -0,0 +1,17 @@ +package cn.craftlabs.platform.api.ping; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1") +public class PingController { + + @GetMapping("/ping") + public Map ping() { + return Map.of("service", "delivery-platform-api", "status", "ok"); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java new file mode 100644 index 0000000..74ba200 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/project/ProjectController.java @@ -0,0 +1,65 @@ +package cn.craftlabs.platform.api.project; + +import cn.craftlabs.platform.api.service.ProjectService; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import cn.craftlabs.platform.api.web.dto.ProjectRequest; +import cn.craftlabs.platform.api.web.dto.ProjectResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** 项目 API。{@code DELETE /{id}} 为物理删除。 */ +@RestController +@RequestMapping("/api/v1/projects") +@Validated +public class ProjectController { + + private final ProjectService projectService; + + public ProjectController(ProjectService projectService) { + this.projectService = projectService; + } + + @GetMapping + public PageResponse list( + @RequestParam(value = "page", defaultValue = "0") @Min(0) int page, + @RequestParam(value = "size", defaultValue = "20") @Min(1) @Max(200) int size, + @RequestParam(value = "customerId", required = false) Long customerId) { + return projectService.page(page, size, customerId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ProjectResponse create(@Valid @RequestBody ProjectRequest request) { + return projectService.create(request); + } + + @GetMapping("/{id}") + public ProjectResponse get(@PathVariable("id") long id) { + return projectService.getById(id); + } + + @PutMapping("/{id}") + public ProjectResponse update( + @PathVariable("id") long id, @Valid @RequestBody ProjectRequest request) { + return projectService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable("id") long id) { + projectService.delete(id); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtAuthenticationFilter.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..e00005a --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtAuthenticationFilter.java @@ -0,0 +1,62 @@ +package cn.craftlabs.platform.api.security; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null && header.startsWith("Bearer ")) { + String raw = header.substring(7).trim(); + if (!raw.isEmpty()) { + try { + Claims claims = jwtService.parseAndValidate(raw); + String subject = claims.getSubject(); + @SuppressWarnings("unchecked") + List roles = claims.get("roles", List.class); + if (roles == null) { + roles = List.of(); + } + List authorities = new ArrayList<>(); + for (String r : roles) { + String role = r.startsWith("ROLE_") ? r : "ROLE_" + r; + authorities.add(new SimpleGrantedAuthority(role)); + } + var auth = new UsernamePasswordAuthenticationToken(subject, null, authorities); + auth.setDetails(claims); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception ignored) { + SecurityContextHolder.clearContext(); + } + } + } + filterChain.doFilter(request, response); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtService.java new file mode 100644 index 0000000..89762d0 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/security/JwtService.java @@ -0,0 +1,47 @@ +package cn.craftlabs.platform.api.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.List; + +@Service +public class JwtService { + + private final SecretKey key; + private final long expirySeconds; + + public JwtService( + @Value("${platform.jwt.secret}") String secret, + @Value("${platform.jwt.expiry-seconds:43200}") long expirySeconds) { + if (secret.length() < 32) { + throw new IllegalArgumentException("platform.jwt.secret must be at least 32 characters"); + } + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.expirySeconds = expirySeconds; + } + + public String createToken(String subject, String displayName, List roles) { + Instant now = Instant.now(); + Instant exp = now.plusSeconds(expirySeconds); + return Jwts.builder() + .subject(subject) + .claim("displayName", displayName) + .claim("roles", roles) + .issuedAt(Date.from(now)) + .expiration(Date.from(exp)) + .signWith(key) + .compact(); + } + + public Claims parseAndValidate(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload(); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CustomerService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CustomerService.java new file mode 100644 index 0000000..fc86274 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/CustomerService.java @@ -0,0 +1,128 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.domain.CustomerStatus; +import cn.craftlabs.platform.api.persistence.customer.PlatformCustomer; +import cn.craftlabs.platform.api.persistence.customer.PlatformCustomerMapper; +import cn.craftlabs.platform.api.web.dto.CustomerRequest; +import cn.craftlabs.platform.api.web.dto.CustomerResponse; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class CustomerService { + + private final PlatformCustomerMapper customerMapper; + + public CustomerService(PlatformCustomerMapper customerMapper) { + this.customerMapper = customerMapper; + } + + @Transactional(readOnly = true) + public PageResponse page(int page, int size, String keyword) { + String kw = StringUtils.hasText(keyword) ? keyword.trim() : null; + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformCustomer.class) + .like(kw != null, PlatformCustomer::getName, kw) + .orderByDesc(PlatformCustomer::getId); + Page mpPage = new Page<>(page + 1L, size); + customerMapper.selectPage(mpPage, q); + List content = + mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList()); + return new PageResponse<>(content, mpPage.getTotal(), page, size); + } + + @Transactional + public CustomerResponse create(CustomerRequest request) { + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + PlatformCustomer c = new PlatformCustomer(); + c.setName(request.getName().trim()); + c.setCreditCode(blankToNull(request.getCreditCode())); + c.setStatus(resolveStatusForCreate(request.getStatus())); + c.setCreatedAt(now); + c.setUpdatedAt(now); + customerMapper.insert(c); + return toResponse(c); + } + + @Transactional(readOnly = true) + public CustomerResponse getById(long id) { + PlatformCustomer c = customerMapper.selectById(id); + if (c == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found"); + } + return toResponse(c); + } + + @Transactional + public CustomerResponse update(long id, CustomerRequest request) { + PlatformCustomer c = customerMapper.selectById(id); + if (c == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found"); + } + c.setName(request.getName().trim()); + if (request.getCreditCode() != null) { + c.setCreditCode(blankToNull(request.getCreditCode())); + } + if (StringUtils.hasText(request.getStatus())) { + c.setStatus(request.getStatus().trim()); + } + c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + customerMapper.updateById(c); + return toResponse(c); + } + + /** + * 软删除:将 {@code status} 置为 {@link CustomerStatus#INACTIVE}(幂等)。 + */ + @Transactional + public void delete(long id) { + PlatformCustomer c = customerMapper.selectById(id); + if (c == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found"); + } + c.setStatus(CustomerStatus.INACTIVE); + c.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + customerMapper.updateById(c); + } + + @Transactional(readOnly = true) + public void requireExists(long id) { + if (customerMapper.selectById(id) == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "customer not found"); + } + } + + private String resolveStatusForCreate(String status) { + if (StringUtils.hasText(status)) { + return status.trim(); + } + return CustomerStatus.ACTIVE; + } + + private static String blankToNull(String s) { + return StringUtils.hasText(s) ? s.trim() : null; + } + + private CustomerResponse toResponse(PlatformCustomer c) { + CustomerResponse r = new CustomerResponse(); + r.setId(c.getId()); + r.setName(c.getName()); + r.setCreditCode(c.getCreditCode()); + r.setStatus(c.getStatus()); + r.setCreatedAt(c.getCreatedAt()); + r.setUpdatedAt(c.getUpdatedAt()); + return r; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DictionaryService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DictionaryService.java new file mode 100644 index 0000000..6999b6a --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/DictionaryService.java @@ -0,0 +1,40 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.persistence.dictionary.PlatformDictionary; +import cn.craftlabs.platform.api.persistence.dictionary.PlatformDictionaryMapper; +import cn.craftlabs.platform.api.web.dto.DictionaryItemResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class DictionaryService { + + private final PlatformDictionaryMapper dictionaryMapper; + + public DictionaryService(PlatformDictionaryMapper dictionaryMapper) { + this.dictionaryMapper = dictionaryMapper; + } + + @Transactional(readOnly = true) + public List listEnabledByType(String dictType) { + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformDictionary.class) + .eq(PlatformDictionary::getDictType, dictType) + .eq(PlatformDictionary::getEnabled, true) + .orderByAsc(PlatformDictionary::getSortOrder) + .orderByAsc(PlatformDictionary::getId); + return dictionaryMapper.selectList(q).stream() + .map( + d -> + new DictionaryItemResponse( + d.getDictCode(), + d.getDictLabel(), + d.getSortOrder() == null ? 0 : d.getSortOrder())) + .collect(Collectors.toList()); + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java new file mode 100644 index 0000000..72ff3d2 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/service/ProjectService.java @@ -0,0 +1,111 @@ +package cn.craftlabs.platform.api.service; + +import cn.craftlabs.platform.api.persistence.project.PlatformProject; +import cn.craftlabs.platform.api.persistence.project.PlatformProjectMapper; +import cn.craftlabs.platform.api.web.dto.PageResponse; +import cn.craftlabs.platform.api.web.dto.ProjectRequest; +import cn.craftlabs.platform.api.web.dto.ProjectResponse; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class ProjectService { + + private static final String DEFAULT_PHASE = "PLANNING"; + + private final PlatformProjectMapper projectMapper; + private final CustomerService customerService; + + public ProjectService(PlatformProjectMapper projectMapper, CustomerService customerService) { + this.projectMapper = projectMapper; + this.customerService = customerService; + } + + @Transactional(readOnly = true) + public PageResponse page(int page, int size, Long customerId) { + LambdaQueryWrapper q = + Wrappers.lambdaQuery(PlatformProject.class) + .eq(customerId != null, PlatformProject::getCustomerId, customerId) + .orderByDesc(PlatformProject::getId); + Page mpPage = new Page<>(page + 1L, size); + projectMapper.selectPage(mpPage, q); + List content = + mpPage.getRecords().stream().map(this::toResponse).collect(Collectors.toList()); + return new PageResponse<>(content, mpPage.getTotal(), page, size); + } + + @Transactional + public ProjectResponse create(ProjectRequest request) { + customerService.requireExists(request.getCustomerId()); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + PlatformProject p = new PlatformProject(); + p.setCustomerId(request.getCustomerId()); + p.setName(request.getName().trim()); + p.setPhase(resolvePhase(request.getPhase())); + p.setCreatedAt(now); + p.setUpdatedAt(now); + projectMapper.insert(p); + return toResponse(p); + } + + @Transactional(readOnly = true) + public ProjectResponse getById(long id) { + PlatformProject p = projectMapper.selectById(id); + if (p == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + } + return toResponse(p); + } + + @Transactional + public ProjectResponse update(long id, ProjectRequest request) { + PlatformProject p = projectMapper.selectById(id); + if (p == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + } + customerService.requireExists(request.getCustomerId()); + p.setCustomerId(request.getCustomerId()); + p.setName(request.getName().trim()); + if (StringUtils.hasText(request.getPhase())) { + p.setPhase(request.getPhase().trim()); + } + p.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); + projectMapper.updateById(p); + return toResponse(p); + } + + /** 物理删除项目行。 */ + @Transactional + public void delete(long id) { + int rows = projectMapper.deleteById(id); + if (rows == 0) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + } + } + + private String resolvePhase(String phase) { + return StringUtils.hasText(phase) ? phase.trim() : DEFAULT_PHASE; + } + + private ProjectResponse toResponse(PlatformProject p) { + ProjectResponse r = new ProjectResponse(); + r.setId(p.getId()); + r.setCustomerId(p.getCustomerId()); + r.setName(p.getName()); + r.setPhase(p.getPhase()); + r.setCreatedAt(p.getCreatedAt()); + r.setUpdatedAt(p.getUpdatedAt()); + return r; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerRequest.java new file mode 100644 index 0000000..80413d5 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerRequest.java @@ -0,0 +1,41 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public class CustomerRequest { + + @NotBlank + @Size(max = 256) + private String name; + + @Size(max = 64) + private String creditCode; + + @Size(max = 32) + private String status; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCreditCode() { + return creditCode; + } + + public void setCreditCode(String creditCode) { + this.creditCode = creditCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerResponse.java new file mode 100644 index 0000000..088bd33 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/CustomerResponse.java @@ -0,0 +1,61 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.time.OffsetDateTime; + +public class CustomerResponse { + + private Long id; + private String name; + private String creditCode; + private String status; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCreditCode() { + return creditCode; + } + + public void setCreditCode(String creditCode) { + this.creditCode = creditCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + 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/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DictionaryItemResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DictionaryItemResponse.java new file mode 100644 index 0000000..f266a4e --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/DictionaryItemResponse.java @@ -0,0 +1,40 @@ +package cn.craftlabs.platform.api.web.dto; + +public class DictionaryItemResponse { + + private String dictCode; + private String dictLabel; + private int sortOrder; + + public DictionaryItemResponse() {} + + public DictionaryItemResponse(String dictCode, String dictLabel, int sortOrder) { + this.dictCode = dictCode; + this.dictLabel = dictLabel; + this.sortOrder = sortOrder; + } + + public String getDictCode() { + return dictCode; + } + + public void setDictCode(String dictCode) { + this.dictCode = dictCode; + } + + public String getDictLabel() { + return dictLabel; + } + + public void setDictLabel(String dictLabel) { + this.dictLabel = dictLabel; + } + + public int getSortOrder() { + return sortOrder; + } + + public void setSortOrder(int sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/PageResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/PageResponse.java new file mode 100644 index 0000000..b2a5281 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/PageResponse.java @@ -0,0 +1,55 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.util.List; + +/** + * 与 Spring Data 风格对齐的分页 JSON(0-based {@code number})。 + */ +public class PageResponse { + + private List content; + private long totalElements; + private int number; + private int size; + + public PageResponse() {} + + public PageResponse(List content, long totalElements, int number, int size) { + this.content = content; + this.totalElements = totalElements; + this.number = number; + this.size = size; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } + + public long getTotalElements() { + return totalElements; + } + + public void setTotalElements(long totalElements) { + this.totalElements = totalElements; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectRequest.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectRequest.java new file mode 100644 index 0000000..ba5c735 --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectRequest.java @@ -0,0 +1,42 @@ +package cn.craftlabs.platform.api.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public class ProjectRequest { + + @NotNull + private Long customerId; + + @NotBlank + @Size(max = 256) + private String name; + + @Size(max = 64) + private String phase; + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPhase() { + return phase; + } + + public void setPhase(String phase) { + this.phase = phase; + } +} diff --git a/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectResponse.java b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectResponse.java new file mode 100644 index 0000000..3e8e5fc --- /dev/null +++ b/services/delivery-platform-api/src/main/java/cn/craftlabs/platform/api/web/dto/ProjectResponse.java @@ -0,0 +1,61 @@ +package cn.craftlabs.platform.api.web.dto; + +import java.time.OffsetDateTime; + +public class ProjectResponse { + + private Long id; + private Long customerId; + private String name; + private String phase; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getCustomerId() { + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPhase() { + return phase; + } + + public void setPhase(String phase) { + this.phase = phase; + } + + 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/delivery-platform-api/src/main/resources/application.yml b/services/delivery-platform-api/src/main/resources/application.yml new file mode 100644 index 0000000..c6f4fa3 --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/application.yml @@ -0,0 +1,36 @@ +server: + port: 8080 + +spring: + application: + name: delivery-platform-api + # 架构约定:生产/联调使用 PostgreSQL 15;本地可 docker compose 见 services/docker-compose.yml + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/craftlabs_platform} + username: ${SPRING_DATASOURCE_USERNAME:craftlabs} + password: ${SPRING_DATASOURCE_PASSWORD:craftlabs} + driver-class-name: org.postgresql.Driver + flyway: + enabled: true + # 与同库共存的 license-webhook-ingress 默认 flyway_schema_history 隔离 + table: flyway_platform_api + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + +management: + endpoints: + web: + exposure: + include: health,info + +# JWT:生产必须通过环境变量覆盖(至少 32 字符) +platform: + jwt: + secret: ${PLATFORM_JWT_SECRET:dev-only-unsafe-change-in-production-32chars!!} + expiry-seconds: ${PLATFORM_JWT_EXPIRY_SECONDS:43200} + +springdoc: + swagger-ui: + path: /swagger-ui.html diff --git a/services/delivery-platform-api/src/main/resources/db/migration/V1__platform_core_tables.sql b/services/delivery-platform-api/src/main/resources/db/migration/V1__platform_core_tables.sql new file mode 100644 index 0000000..148d4b6 --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/db/migration/V1__platform_core_tables.sql @@ -0,0 +1,30 @@ +-- M1:交付平台核心表(PostgreSQL 15;H2 MODE=PostgreSQL 单测) +CREATE TABLE platform_customer ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(256) NOT NULL, + credit_code VARCHAR(64), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE platform_project ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL REFERENCES platform_customer (id), + name VARCHAR(256) NOT NULL, + phase VARCHAR(64) NOT NULL DEFAULT 'PLANNING', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_platform_project_customer_id ON platform_project (customer_id); + +CREATE TABLE platform_dictionary ( + id BIGSERIAL PRIMARY KEY, + dict_type VARCHAR(64) NOT NULL, + dict_code VARCHAR(64) NOT NULL, + dict_label VARCHAR(256) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT uq_platform_dictionary_type_code UNIQUE (dict_type, dict_code) +); diff --git a/services/delivery-platform-api/src/main/resources/db/migration/V2__seed_project_phase_dictionary.sql b/services/delivery-platform-api/src/main/resources/db/migration/V2__seed_project_phase_dictionary.sql new file mode 100644 index 0000000..bb0ddc8 --- /dev/null +++ b/services/delivery-platform-api/src/main/resources/db/migration/V2__seed_project_phase_dictionary.sql @@ -0,0 +1,5 @@ +-- M1:项目阶段字典(中文标签) +INSERT INTO platform_dictionary (dict_type, dict_code, dict_label, sort_order, enabled) +VALUES ('PROJECT_PHASE', 'PLANNING', '规划中', 10, TRUE), + ('PROJECT_PHASE', 'IN_PROGRESS', '进行中', 20, TRUE), + ('PROJECT_PHASE', 'DELIVERED', '已交付', 30, TRUE); 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 new file mode 100644 index 0000000..8519c96 --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/auth/AuthControllerTest.java @@ -0,0 +1,39 @@ +package cn.craftlabs.platform.api.auth; + +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.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void loginSuccess() throws Exception { + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"admin\",\"password\":\"admin\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").exists()); + } + + @Test + void loginFail() throws Exception { + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"x\",\"password\":\"y\"}")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contract/OpenApiContractSnapshotTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contract/OpenApiContractSnapshotTest.java new file mode 100644 index 0000000..bf997f5 --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/contract/OpenApiContractSnapshotTest.java @@ -0,0 +1,76 @@ +package cn.craftlabs.platform.api.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 将 springdoc 产出的 OpenAPI 固化为仓库内 {@code contracts/openapi/delivery-platform-api.json}。 + * + *

更新快照:{@code UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest}(在 {@code + * services/delivery-platform-api} 模块目录下执行)。 + */ +@SpringBootTest +@AutoConfigureMockMvc +class OpenApiContractSnapshotTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Autowired + private MockMvc mockMvc; + + @Test + void openApiMatchesCommittedContract() throws Exception { + MvcResult result = + mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn(); + String raw = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode actual = MAPPER.readTree(raw); + String normalized = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(actual); + + Path contractFile = repositoryRoot().resolve("contracts/openapi/delivery-platform-api.json"); + boolean update = "1".equals(System.getenv("UPDATE_OPENAPI")); + + if (update) { + Files.createDirectories(contractFile.getParent()); + Files.writeString(contractFile, normalized, StandardCharsets.UTF_8); + return; + } + + assertThat(Files.isRegularFile(contractFile)) + .as( + "缺少契约文件 %s;请执行: cd services/delivery-platform-api && UPDATE_OPENAPI=1 mvn test -Dtest=OpenApiContractSnapshotTest", + contractFile) + .isTrue(); + + JsonNode expected = MAPPER.readTree(Files.readString(contractFile, StandardCharsets.UTF_8)); + assertThat(actual) + .as( + "OpenAPI 与快照不一致。若变更为刻意更新契约,请设置 UPDATE_OPENAPI=1 重新导出并提交 %s", + contractFile) + .isEqualTo(expected); + } + + /** Surefire 的 user.dir 为当前模块根(delivery-platform-api)。 */ + private static Path repositoryRoot() { + Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + if (dir.endsWith("delivery-platform-api")) { + return dir.getParent().getParent(); + } + return dir; + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/customer/CustomerControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/customer/CustomerControllerTest.java new file mode 100644 index 0000000..7477f4f --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/customer/CustomerControllerTest.java @@ -0,0 +1,91 @@ +package cn.craftlabs.platform.api.customer; + +import cn.craftlabs.platform.api.support.JwtTestSupport; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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 org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CustomerControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void customerCrudHappyPath() throws Exception { + String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper); + String auth = "Bearer " + token; + + String createBody = "{\"name\":\"测试客户\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}"; + String created = + mockMvc.perform( + post("/api/v1/customers") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(createBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNumber()) + .andExpect(jsonPath("$.name").value("测试客户")) + .andExpect(jsonPath("$.creditCode").value("91110000MA")) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andReturn() + .getResponse() + .getContentAsString(); + + long id = objectMapper.readTree(created).get("id").asLong(); + + mockMvc.perform(get("/api/v1/customers").header("Authorization", auth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.number").value(0)) + .andExpect(jsonPath("$.size").value(20)); + + mockMvc.perform(get("/api/v1/customers").param("keyword", "测试").header("Authorization", auth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)); + + mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("测试客户")); + + mockMvc.perform( + put("/api/v1/customers/" + id) + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"已更名\",\"creditCode\":\"91110000MA\",\"status\":\"ACTIVE\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("已更名")); + + mockMvc.perform(delete("/api/v1/customers/" + id).header("Authorization", auth)) + .andExpect(status().isNoContent()); + + JsonNode after = + objectMapper.readTree( + mockMvc.perform(get("/api/v1/customers/" + id).header("Authorization", auth)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString()); + assertThat(after.get("status").asText()).isEqualTo("INACTIVE"); + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/dictionary/DictionaryControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/dictionary/DictionaryControllerTest.java new file mode 100644 index 0000000..f15819a --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/dictionary/DictionaryControllerTest.java @@ -0,0 +1,43 @@ +package cn.craftlabs.platform.api.dictionary; + +import cn.craftlabs.platform.api.support.JwtTestSupport; +import com.fasterxml.jackson.databind.ObjectMapper; +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.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class DictionaryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void listProjectPhaseDictionary() throws Exception { + String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper); + mockMvc.perform( + get("/api/v1/dictionaries/PROJECT_PHASE") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(3))) + .andExpect(jsonPath("$[0].dictCode").value("PLANNING")) + .andExpect(jsonPath("$[0].dictLabel").value("规划中")) + .andExpect(jsonPath("$[1].dictCode").value("IN_PROGRESS")) + .andExpect(jsonPath("$[1].dictLabel").value("进行中")) + .andExpect(jsonPath("$[2].dictCode").value("DELIVERED")) + .andExpect(jsonPath("$[2].dictLabel").value("已交付")); + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/ping/PingWithJwtTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/ping/PingWithJwtTest.java new file mode 100644 index 0000000..4341f1b --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/ping/PingWithJwtTest.java @@ -0,0 +1,51 @@ +package cn.craftlabs.platform.api.ping; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 org.springframework.test.web.servlet.MvcResult; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class PingWithJwtTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void pingRequiresJwt() throws Exception { + mockMvc.perform(get("/api/v1/ping")).andExpect(status().isUnauthorized()); + } + + @Test + void pingWithBearerFromLogin() throws Exception { + MvcResult login = + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"admin\",\"password\":\"admin\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isString()) + .andReturn(); + String token = + objectMapper.readTree(login.getResponse().getContentAsString()).get("token").asText(); + assertThat(token).isNotBlank(); + + mockMvc.perform(get("/api/v1/ping").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("ok")); + } +} diff --git a/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/project/ProjectControllerTest.java b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/project/ProjectControllerTest.java new file mode 100644 index 0000000..37913e4 --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/project/ProjectControllerTest.java @@ -0,0 +1,95 @@ +package cn.craftlabs.platform.api.project; + +import cn.craftlabs.platform.api.support.JwtTestSupport; +import com.fasterxml.jackson.databind.ObjectMapper; +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 org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ProjectControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void projectCrudHappyPath() throws Exception { + String token = JwtTestSupport.obtainBearerToken(mockMvc, objectMapper); + String auth = "Bearer " + token; + + String cust = + mockMvc.perform( + post("/api/v1/customers") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"项目所属客户\"}")) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long customerId = objectMapper.readTree(cust).get("id").asLong(); + + String projBody = + "{\"customerId\":" + + customerId + + ",\"name\":\"交付项目A\",\"phase\":\"PLANNING\"}"; + String created = + mockMvc.perform( + post("/api/v1/projects") + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content(projBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.phase").value("PLANNING")) + .andReturn() + .getResponse() + .getContentAsString(); + long projectId = objectMapper.readTree(created).get("id").asLong(); + + mockMvc.perform( + get("/api/v1/projects") + .param("customerId", String.valueOf(customerId)) + .header("Authorization", auth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].name").value("交付项目A")); + + mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.customerId").value(customerId)); + + mockMvc.perform( + put("/api/v1/projects/" + projectId) + .header("Authorization", auth) + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"customerId\":" + + customerId + + ",\"name\":\"交付项目A-改\",\"phase\":\"IN_PROGRESS\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("交付项目A-改")) + .andExpect(jsonPath("$.phase").value("IN_PROGRESS")); + + mockMvc.perform(delete("/api/v1/projects/" + projectId).header("Authorization", auth)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/v1/projects/" + projectId).header("Authorization", auth)) + .andExpect(status().isNotFound()); + } +} 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 new file mode 100644 index 0000000..57cf67e --- /dev/null +++ b/services/delivery-platform-api/src/test/java/cn/craftlabs/platform/api/support/JwtTestSupport.java @@ -0,0 +1,24 @@ +package cn.craftlabs.platform.api.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +public final class JwtTestSupport { + + private JwtTestSupport() {} + + public static String obtainBearerToken(MockMvc mockMvc, ObjectMapper objectMapper) throws Exception { + MvcResult login = + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"admin\",\"password\":\"admin\"}")) + .andReturn(); + String body = login.getResponse().getContentAsString(); + return objectMapper.readTree(body).get("token").asText(); + } +} diff --git a/services/delivery-platform-api/src/test/resources/application.yml b/services/delivery-platform-api/src/test/resources/application.yml new file mode 100644 index 0000000..ab7183f --- /dev/null +++ b/services/delivery-platform-api/src/test/resources/application.yml @@ -0,0 +1,14 @@ +# 单测不依赖本机 PostgreSQL:H2 模拟 PostgreSQL 语法习惯(版本仍以生产 PG15 为准) +spring: + datasource: + url: jdbc:h2:mem:craftlabs_platform;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH + driver-class-name: org.h2.Driver + username: sa + password: + flyway: + enabled: true + table: flyway_platform_api + +platform: + jwt: + secret: unit-test-jwt-secret-at-least-32-chars-ok diff --git a/services/docker-compose.yml b/services/docker-compose.yml new file mode 100644 index 0000000..3e7e402 --- /dev/null +++ b/services/docker-compose.yml @@ -0,0 +1,10 @@ +# 本地/联调:PostgreSQL 15(与架构文档一致) +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: craftlabs + POSTGRES_PASSWORD: craftlabs + POSTGRES_DB: craftlabs_platform + ports: + - "5432:5432" diff --git a/services/license-webhook-ingress/pom.xml b/services/license-webhook-ingress/pom.xml new file mode 100644 index 0000000..ea5ed46 --- /dev/null +++ b/services/license-webhook-ingress/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + + cn.craftlabs.platform + craftlabs-platform-services-parent + 0.1.0-SNAPSHOT + + + license-webhook-ingress + License Webhook Ingress + 比特规则 Callback 接入(I5 扩展持久化/MQ);默认端口 8081 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + + 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 new file mode 100644 index 0000000..7a38bad --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackIngestController.java @@ -0,0 +1,59 @@ +package cn.craftlabs.platform.webhook; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.Optional; + +/** + * 比特规则 Callback 入口(M5 / BP-06)。 + * I1:验 token + 打日志;I5:落库/MQ + 幂等键。 + */ +@RestController +public class CallbackIngestController { + + private static final Logger log = LoggerFactory.getLogger(CallbackIngestController.class); + + public static final String HEADER_TOKEN = "x-bitanswer-token"; + + private final CallbackReceiptService receiptService; + + @Value("${craftlabs.webhook.expected-token:}") + private String expectedToken; + + public CallbackIngestController(CallbackReceiptService receiptService) { + this.receiptService = receiptService; + } + + @PostMapping("/webhook/bitanswer/callback") + public ResponseEntity> ingest( + @RequestHeader(value = HEADER_TOKEN, required = false) String token, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @RequestBody String rawBody) { + + if (expectedToken != null && !expectedToken.isBlank()) { + if (token == null || !expectedToken.equals(token)) { + log.warn("callback rejected: bad or missing {}", HEADER_TOKEN); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } + + int bytes = rawBody != null ? rawBody.length() : 0; + receiptService.recordReceipt(idempotencyKey, bytes); + + log.info( + "bitanswer callback accepted idempotencyKey={} bytes={}", + Optional.ofNullable(idempotencyKey).orElse("-"), + bytes); + + return ResponseEntity.ok(Map.of("status", "accepted")); + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackReceiptService.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackReceiptService.java new file mode 100644 index 0000000..eba7c83 --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/CallbackReceiptService.java @@ -0,0 +1,37 @@ +package cn.craftlabs.platform.webhook; + +import cn.craftlabs.platform.webhook.persistence.WebhookCallbackReceipt; +import cn.craftlabs.platform.webhook.persistence.WebhookCallbackReceiptMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +@Service +public class CallbackReceiptService { + + private static final Logger log = LoggerFactory.getLogger(CallbackReceiptService.class); + + private final WebhookCallbackReceiptMapper mapper; + + public CallbackReceiptService(WebhookCallbackReceiptMapper mapper) { + this.mapper = mapper; + } + + /** + * 记录幂等键;重复键忽略(对比特仍返回 2xx)。 + */ + public void recordReceipt(String idempotencyKey, int bodyBytes) { + if (idempotencyKey == null || idempotencyKey.isBlank()) { + return; + } + var row = new WebhookCallbackReceipt(); + row.setIdempotencyKey(idempotencyKey.trim()); + row.setBodyBytes(bodyBytes); + try { + mapper.insert(row); + } catch (DataIntegrityViolationException e) { + log.debug("callback idempotent replay key={}", idempotencyKey); + } + } +} 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 new file mode 100644 index 0000000..bc1acf4 --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/WebhookApplication.java @@ -0,0 +1,14 @@ +package cn.craftlabs.platform.webhook; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("cn.craftlabs.platform.webhook.persistence") +public class WebhookApplication { + + public static void main(String[] args) { + SpringApplication.run(WebhookApplication.class, args); + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceipt.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceipt.java new file mode 100644 index 0000000..ab82a6c --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceipt.java @@ -0,0 +1,52 @@ +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.Instant; + +@TableName("webhook_callback_receipt") +public class WebhookCallbackReceipt { + + @TableId(type = IdType.AUTO) + private Long id; + + private String idempotencyKey; + + private Integer bodyBytes; + + private Instant createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Integer getBodyBytes() { + return bodyBytes; + } + + public void setBodyBytes(Integer bodyBytes) { + this.bodyBytes = bodyBytes; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceiptMapper.java b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceiptMapper.java new file mode 100644 index 0000000..9eca935 --- /dev/null +++ b/services/license-webhook-ingress/src/main/java/cn/craftlabs/platform/webhook/persistence/WebhookCallbackReceiptMapper.java @@ -0,0 +1,7 @@ +package cn.craftlabs.platform.webhook.persistence; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface WebhookCallbackReceiptMapper extends BaseMapper {} diff --git a/services/license-webhook-ingress/src/main/resources/application.yml b/services/license-webhook-ingress/src/main/resources/application.yml new file mode 100644 index 0000000..c842819 --- /dev/null +++ b/services/license-webhook-ingress/src/main/resources/application.yml @@ -0,0 +1,32 @@ +server: + port: 8081 + +spring: + application: + name: license-webhook-ingress + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/craftlabs_platform} + username: ${SPRING_DATASOURCE_USERNAME:craftlabs} + password: ${SPRING_DATASOURCE_PASSWORD:craftlabs} + driver-class-name: org.postgresql.Driver + flyway: + enabled: true + # 与 delivery-platform-api 的 flyway_platform_api 并存于同一 PostgreSQL 时互不覆盖迁移历史 + table: flyway_webhook + codec: + max-in-memory-size: 512KB + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + +management: + endpoints: + web: + exposure: + include: health,info + +# 开发可设环境变量 CRAFTLABS_WEBHOOK_EXPECTED_TOKEN;空则不做 token 校验(仅本地) +craftlabs: + webhook: + expected-token: ${CRAFTLABS_WEBHOOK_EXPECTED_TOKEN:} diff --git a/services/license-webhook-ingress/src/main/resources/db/migration/V1__webhook_callback_receipt.sql b/services/license-webhook-ingress/src/main/resources/db/migration/V1__webhook_callback_receipt.sql new file mode 100644 index 0000000..4e11766 --- /dev/null +++ b/services/license-webhook-ingress/src/main/resources/db/migration/V1__webhook_callback_receipt.sql @@ -0,0 +1,8 @@ +-- I1:Callback 幂等键落库(PostgreSQL 15 / H2 PG 模式);NULL idempotency_key 允许多条 +CREATE TABLE webhook_callback_receipt ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + idempotency_key VARCHAR(512), + body_bytes INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_webhook_idempotency UNIQUE (idempotency_key) +); diff --git a/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/CallbackIngestControllerTest.java b/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/CallbackIngestControllerTest.java new file mode 100644 index 0000000..78d2e65 --- /dev/null +++ b/services/license-webhook-ingress/src/test/java/cn/craftlabs/platform/webhook/CallbackIngestControllerTest.java @@ -0,0 +1,53 @@ +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.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class CallbackIngestControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void rejectsWithoutToken() throws Exception { + mockMvc.perform( + post("/webhook/bitanswer/callback") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void acceptsWithToken() throws Exception { + mockMvc.perform( + post("/webhook/bitanswer/callback") + .header(CallbackIngestController.HEADER_TOKEN, "test-secret") + .header("Idempotency-Key", "k1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"event\":\"sn:post_activate\"}")) + .andExpect(status().isOk()); + } + + @Test + void duplicateIdempotencyKeyStillOk() throws Exception { + String body = "{\"event\":\"dup\"}"; + for (int i = 0; i < 2; i++) { + mockMvc.perform( + post("/webhook/bitanswer/callback") + .header(CallbackIngestController.HEADER_TOKEN, "test-secret") + .header("Idempotency-Key", "stable-key") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + } + } +} diff --git a/services/license-webhook-ingress/src/test/resources/application.yml b/services/license-webhook-ingress/src/test/resources/application.yml new file mode 100644 index 0000000..ed198f7 --- /dev/null +++ b/services/license-webhook-ingress/src/test/resources/application.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:h2:mem:webhook_ingress;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH + driver-class-name: org.h2.Driver + username: sa + password: + flyway: + enabled: false + sql: + init: + mode: always + schema-locations: classpath:schema-webhook-test.sql + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + +craftlabs: + webhook: + expected-token: test-secret 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 new file mode 100644 index 0000000..3bd3954 --- /dev/null +++ b/services/license-webhook-ingress/src/test/resources/schema-webhook-test.sql @@ -0,0 +1,8 @@ +-- 单测建表(与 db/migration/V1 语义一致);单测关闭 Flyway,由 spring.sql.init 执行 +CREATE TABLE IF NOT EXISTS webhook_callback_receipt ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + idempotency_key VARCHAR(512), + body_bytes INT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_webhook_idempotency UNIQUE (idempotency_key) +); diff --git a/services/pom.xml b/services/pom.xml new file mode 100644 index 0000000..73525af --- /dev/null +++ b/services/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + cn.craftlabs.platform + craftlabs-platform-services-parent + 0.1.0-SNAPSHOT + pom + CraftLabs platform services (I1 scaffold) + 与 craftlabs-auth-* SDK 分构建线;各子模块单独产出可执行 Fat JAR(迭代 I1~I5 扩展)。 + + + license-webhook-ingress + delivery-platform-api + + + + 17 + 3.4.5 + + 3.5.15 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + ban-craftlabs-auth-bitanswer + + enforce + + validate + + true + + + true + + cn.craftlabs:craftlabs-auth-bitanswer + + 平台服务禁止依赖 craftlabs-auth-bitanswer(JNI 客户端 SDK)。 + + + + + + + + + +