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