mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
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
This commit is contained in:
@@ -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
|
||||
@@ -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 类型(非必选)。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`。
|
||||
@@ -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://<api-host>: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**;回滚需 **人工迁移脚本** 或从备份恢复(生产变更前应备份)。
|
||||
@@ -0,0 +1,106 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>cn.craftlabs.platform</groupId>
|
||||
<artifactId>craftlabs-platform-services-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>delivery-platform-api</artifactId>
|
||||
<name>Delivery Platform API</name>
|
||||
<description>客户商务与交付管理平台 API(I1 登录壳);默认端口 8080。与 SDK 分线,禁止依赖 craftlabs-auth-bitanswer。</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.8.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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<String, Object> login(@RequestBody Map<String, String> 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");
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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")));
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
+67
@@ -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}} 为<strong>软删除</strong>:将 {@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<CustomerResponse> 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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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<DictionaryItemResponse> listByType(@PathVariable("type") String type) {
|
||||
return dictionaryService.listEnabledByType(type);
|
||||
}
|
||||
}
|
||||
+9
@@ -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() {}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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<PlatformCustomer> {}
|
||||
+75
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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<PlatformDictionary> {}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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<PlatformProject> {}
|
||||
+17
@@ -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<String, String> ping() {
|
||||
return Map.of("service", "delivery-platform-api", "status", "ok");
|
||||
}
|
||||
}
|
||||
+65
@@ -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}} 为<strong>物理删除</strong>。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/projects")
|
||||
@Validated
|
||||
public class ProjectController {
|
||||
|
||||
private final ProjectService projectService;
|
||||
|
||||
public ProjectController(ProjectService projectService) {
|
||||
this.projectService = projectService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PageResponse<ProjectResponse> 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);
|
||||
}
|
||||
}
|
||||
+62
@@ -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<String> roles = claims.get("roles", List.class);
|
||||
if (roles == null) {
|
||||
roles = List.of();
|
||||
}
|
||||
List<SimpleGrantedAuthority> 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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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<String> 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();
|
||||
}
|
||||
}
|
||||
+128
@@ -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<CustomerResponse> page(int page, int size, String keyword) {
|
||||
String kw = StringUtils.hasText(keyword) ? keyword.trim() : null;
|
||||
LambdaQueryWrapper<PlatformCustomer> q =
|
||||
Wrappers.lambdaQuery(PlatformCustomer.class)
|
||||
.like(kw != null, PlatformCustomer::getName, kw)
|
||||
.orderByDesc(PlatformCustomer::getId);
|
||||
Page<PlatformCustomer> mpPage = new Page<>(page + 1L, size);
|
||||
customerMapper.selectPage(mpPage, q);
|
||||
List<CustomerResponse> 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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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<DictionaryItemResponse> listEnabledByType(String dictType) {
|
||||
LambdaQueryWrapper<PlatformDictionary> 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());
|
||||
}
|
||||
}
|
||||
+111
@@ -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<ProjectResponse> page(int page, int size, Long customerId) {
|
||||
LambdaQueryWrapper<PlatformProject> q =
|
||||
Wrappers.lambdaQuery(PlatformProject.class)
|
||||
.eq(customerId != null, PlatformProject::getCustomerId, customerId)
|
||||
.orderByDesc(PlatformProject::getId);
|
||||
Page<PlatformProject> mpPage = new Page<>(page + 1L, size);
|
||||
projectMapper.selectPage(mpPage, q);
|
||||
List<ProjectResponse> 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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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<T> {
|
||||
|
||||
private List<T> content;
|
||||
private long totalElements;
|
||||
private int number;
|
||||
private int size;
|
||||
|
||||
public PageResponse() {}
|
||||
|
||||
public PageResponse(List<T> content, long totalElements, int number, int size) {
|
||||
this.content = content;
|
||||
this.totalElements = totalElements;
|
||||
this.number = number;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public List<T> getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(List<T> 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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+30
@@ -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)
|
||||
);
|
||||
+5
@@ -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);
|
||||
+39
@@ -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());
|
||||
}
|
||||
}
|
||||
+76
@@ -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}。
|
||||
*
|
||||
* <p>更新快照:{@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;
|
||||
}
|
||||
}
|
||||
+91
@@ -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");
|
||||
}
|
||||
}
|
||||
+43
@@ -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("已交付"));
|
||||
}
|
||||
}
|
||||
+51
@@ -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"));
|
||||
}
|
||||
}
|
||||
+95
@@ -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());
|
||||
}
|
||||
}
|
||||
+24
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>cn.craftlabs.platform</groupId>
|
||||
<artifactId>craftlabs-platform-services-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>license-webhook-ingress</artifactId>
|
||||
<name>License Webhook Ingress</name>
|
||||
<description>比特规则 Callback 接入(I5 扩展持久化/MQ);默认端口 8081</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+59
@@ -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<Map<String, String>> 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"));
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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<WebhookCallbackReceipt> {}
|
||||
@@ -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:}
|
||||
+8
@@ -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)
|
||||
);
|
||||
+53
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>cn.craftlabs.platform</groupId>
|
||||
<artifactId>craftlabs-platform-services-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>CraftLabs platform services (I1 scaffold)</name>
|
||||
<description>与 craftlabs-auth-* SDK 分构建线;各子模块单独产出可执行 Fat JAR(迭代 I1~I5 扩展)。</description>
|
||||
|
||||
<modules>
|
||||
<module>license-webhook-ingress</module>
|
||||
<module>delivery-platform-api</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<spring-boot.version>3.4.5</spring-boot.version>
|
||||
<!-- 平台数据访问:PostgreSQL 15 + MyBatis-Plus(Spring Boot 3 用 boot3-starter) -->
|
||||
<mybatis-plus.version>3.5.15</mybatis-plus.version>
|
||||
</properties>
|
||||
|
||||
<!--
|
||||
说明:选用 Spring Boot 3.4.x LTS 以保证多数环境可解析;
|
||||
产品文档目标 4.0.* 可在父 POM 升级 spring-boot.version 并全量回归。
|
||||
-->
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>${java.version}</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- 子模块显式引用后,在 validate 阶段禁止引入客户端 JNI SDK -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>ban-craftlabs-auth-bitanswer</id>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<phase>validate</phase>
|
||||
<configuration>
|
||||
<fail>true</fail>
|
||||
<rules>
|
||||
<bannedDependencies>
|
||||
<searchTransitive>true</searchTransitive>
|
||||
<excludes>
|
||||
<exclude>cn.craftlabs:craftlabs-auth-bitanswer</exclude>
|
||||
</excludes>
|
||||
<message>平台服务禁止依赖 craftlabs-auth-bitanswer(JNI 客户端 SDK)。</message>
|
||||
</bannedDependencies>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
</project>
|
||||
Reference in New Issue
Block a user