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:
2026-04-06 21:04:56 +08:00
parent 76ff98db87
commit 3f577b34d5
57 changed files with 3170 additions and 0 deletions
+52
View File
@@ -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
+27
View File
@@ -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 认证;迭代扩展 M1M11",
"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"
}
}
}
}
+51
View File
@@ -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 单测使用 **H2PostgreSQL 兼容模式)**,不强制本机安装 PostgreSQL。
## 环境变量(数据源,可选)
覆盖默认 JDBC 时使用:`SPRING_DATASOURCE_URL``SPRING_DATASOURCE_USERNAME``SPRING_DATASOURCE_PASSWORD`
+97
View File
@@ -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** | 比特 CallbackFlyway 表 **`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**;回滚需 **人工迁移脚本** 或从备份恢复(生产变更前应备份)。
+106
View File
@@ -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>
@@ -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);
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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 认证;迭代扩展 M1M11")
.version("0.1.0-SNAPSHOT"))
.components(
new Components()
.addSecuritySchemes(
bearer,
new SecurityScheme()
.name(bearer)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
@@ -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;
/**
* I1JWTBearer)保护业务 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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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() {}
}
@@ -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;
}
}
@@ -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> {}
@@ -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;
}
}
@@ -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> {}
@@ -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;
}
}
@@ -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> {}
@@ -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");
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,55 @@
package cn.craftlabs.platform.api.web.dto;
import java.util.List;
/**
* 与 Spring Data 风格对齐的分页 JSON0-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;
}
}
@@ -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;
}
}
@@ -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
@@ -0,0 +1,30 @@
-- M1:交付平台核心表(PostgreSQL 15H2 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)
);
@@ -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);
@@ -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());
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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("已交付"));
}
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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 @@
# 单测不依赖本机 PostgreSQLH2 模拟 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
+10
View File
@@ -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"
+71
View File
@@ -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>
@@ -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"));
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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:}
@@ -0,0 +1,8 @@
-- I1Callback 幂等键落库(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)
);
@@ -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)
);
+100
View File
@@ -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-PlusSpring 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-bitanswerJNI 客户端 SDK)。</message>
</bannedDependencies>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>