Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a5fb8e26 | |||
| 43545aa715 | |||
| 82255a6feb | |||
| bc18930b7a | |||
| 3f30d574d6 | |||
| 234a166924 |
+5
-18
@@ -1,18 +1,5 @@
|
||||
# Build and Release Folders
|
||||
bin-debug/
|
||||
bin-release/
|
||||
[Oo]bj/
|
||||
[Bb]in/
|
||||
|
||||
# Other files and folders
|
||||
.settings/
|
||||
|
||||
# Executables
|
||||
*.swf
|
||||
*.air
|
||||
*.ipa
|
||||
*.apk
|
||||
|
||||
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
|
||||
# should NOT be excluded as they contain compiler settings and other important
|
||||
# information for Eclipse / Flash Builder.
|
||||
target/
|
||||
logs/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## 代码实现原则
|
||||
- 优先保证业务正确性、可维护性、可读性,再考虑炫技。
|
||||
- 修改代码时忽略.sql文件,如果遇到需要改表结构,直接发我sql。
|
||||
## 日志规范
|
||||
- 关键业务节点必须补充必要日志,方便线上排查。
|
||||
- 不要打印密码、Token、手机号、身份证、支付密钥等敏感信息。
|
||||
- 异常日志必须包含足够定位问题的信息。
|
||||
## 回复规范
|
||||
- 永远用中文回复。
|
||||
@@ -0,0 +1,42 @@
|
||||
# CadLabel Backend
|
||||
|
||||
Spring Boot backend skeleton for CadLabel.
|
||||
|
||||
## Stack
|
||||
|
||||
- Java 21
|
||||
- Spring Boot 3.4.4
|
||||
- Maven
|
||||
- MyBatis-Plus
|
||||
- MySQL
|
||||
- Tencent Cloud COS SDK
|
||||
|
||||
This project intentionally does not include Spring Security, JWT, login APIs, or user-system code.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
mvn test
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
The default profile is `dev`. Configure these environment variables before starting against a real database and COS bucket:
|
||||
|
||||
```bash
|
||||
export APP_DB_URL='jdbc:mysql://localhost:3306/cadlabel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true'
|
||||
export APP_DB_USERNAME='root'
|
||||
export APP_DB_PASSWORD=''
|
||||
|
||||
export APP_COS_SECRET_ID=''
|
||||
export APP_COS_SECRET_KEY=''
|
||||
export APP_COS_BUCKET=''
|
||||
export APP_COS_REGION='ap-beijing'
|
||||
export APP_COS_PUBLIC_BASE_URL=''
|
||||
```
|
||||
|
||||
## COS APIs
|
||||
|
||||
- `POST /api/cos/upload-sessions` issues temporary STS credentials for direct browser upload.
|
||||
- `POST /api/cos/objects` accepts multipart file upload and stores the file through the backend.
|
||||
|
||||
Both endpoints return the standard `ApiResponse<T>` shape.
|
||||
@@ -0,0 +1,731 @@
|
||||
# CAD 模型与标注接口文档
|
||||
|
||||
面向前端联调使用,覆盖 `CadModelController` 与 `CadAnnotationController` 暴露的接口。
|
||||
|
||||
## 1. 通用约定
|
||||
|
||||
### 1.1 基础信息
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 默认服务地址 | `http://localhost:8891` |
|
||||
| 接口前缀 | `/api/cad-models` |
|
||||
| 请求/响应格式 | `application/json` |
|
||||
| 时间格式 | `yyyy-MM-dd HH:mm:ss`,时区 `Asia/Shanghai` |
|
||||
| 字段命名 | JSON 使用小驼峰,如 `modelName` |
|
||||
| 空字段 | 后端全局配置为 `non_null`,响应中的 `null` 字段默认不返回 |
|
||||
|
||||
### 1.2 统一响应结构
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "success",
|
||||
"data": {},
|
||||
"requestId": "b3f8..."
|
||||
}
|
||||
```
|
||||
|
||||
失败响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_REQUEST",
|
||||
"message": "page 必须大于等于 1",
|
||||
"requestId": "b3f8..."
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 常见状态码与错误码
|
||||
|
||||
| HTTP 状态码 | code | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 200 | `OK` | 请求成功 |
|
||||
| 404 | `NOT_FOUND` | 模型、建模历史、结构、功能或请求路径不存在 |
|
||||
| 409 | `CAD_MODEL_DUPLICATE` | 修改模型时,同分类下源 SolidWorks 文件名已存在 |
|
||||
| 422 | `INVALID_REQUEST` | 参数校验失败、请求体不合法、方法/媒体类型不支持 |
|
||||
| 500 | `INTERNAL_ERROR` | 系统异常或数据库异常 |
|
||||
|
||||
### 1.4 分页响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"size": 10,
|
||||
"total": 0
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `items` | array | 当前页数据 |
|
||||
| `page` | number | 当前页码,从 1 开始 |
|
||||
| `size` | number | 每页条数 |
|
||||
| `total` | number | 总记录数 |
|
||||
|
||||
## 2. 模型接口
|
||||
|
||||
### 2.1 分页查询 CAD 模型列表
|
||||
|
||||
`GET /api/cad-models`
|
||||
|
||||
按创建时间倒序、ID 倒序返回模型列表。
|
||||
|
||||
#### Query 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `page` | number | 否 | `1` | `>= 1` | 页码 |
|
||||
| `size` | number | 否 | `10` | `1-100` | 每页条数 |
|
||||
| `categoryId` | number | 否 | - | 正整数 | 分类 ID |
|
||||
| `convertStatus` | string | 否 | - | `pending` / `success` / `failed` / `partial_failed` | 转换状态 |
|
||||
| `keyword` | string | 否 | - | - | 关键字,匹配 `modelName` 或 `sourceSwFilename` |
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`PageResponse<CadModel>`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"categoryId": 10,
|
||||
"modelName": "支架模型",
|
||||
"sourceSwFilename": "bracket.SLDPRT",
|
||||
"sourceSwPath": "uploads/bracket.SLDPRT",
|
||||
"featureTreeJsonPath": "uploads/bracket-feature-tree.json",
|
||||
"viewPath": "uploads/bracket.glb",
|
||||
"applicationScenario": "机械装配",
|
||||
"material": "铝合金",
|
||||
"manufacturingMethod": "CNC",
|
||||
"unit": "mm",
|
||||
"userRequirement": "减重并保证强度",
|
||||
"designDescription": "含多个孔位和倒角",
|
||||
"convertStatus": "success",
|
||||
"createdAt": "2026-07-08 16:20:00",
|
||||
"updatedAt": "2026-07-08 16:20:00"
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"size": 10,
|
||||
"total": 1
|
||||
},
|
||||
"requestId": "b3f8..."
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 查询 CAD 模型详情
|
||||
|
||||
`GET /api/cad-models/{id}`
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | number | 是 | 正整数 | 模型 ID |
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadModel`
|
||||
|
||||
### 2.3 修改 CAD 模型基础信息
|
||||
|
||||
`POST /api/cad-models/{id}`
|
||||
|
||||
支持部分字段更新。请求体可为空;为空或所有字段均未传时不更新,直接返回当前模型详情。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | number | 是 | 正整数 | 模型 ID |
|
||||
|
||||
#### 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `categoryId` | number | 否 | 正整数 | 分类 ID |
|
||||
| `modelName` | string | 否 | 最大 255 字符 | 模型名称 |
|
||||
| `sourceSwFilename` | string | 否 | 最大 255 字符 | 源 SolidWorks 文件名 |
|
||||
| `sourceSwPath` | string | 否 | 最大 1024 字符 | 源文件路径 |
|
||||
| `featureTreeJsonPath` | string | 否 | 最大 1024 字符 | 特征树 JSON 路径 |
|
||||
| `viewPath` | string | 否 | 最大 1024 字符 | 预览文件路径 |
|
||||
| `applicationScenario` | string | 否 | 最大 255 字符 | 应用场景 |
|
||||
| `material` | string | 否 | 最大 255 字符 | 材料 |
|
||||
| `manufacturingMethod` | string | 否 | 最大 255 字符 | 制造方式 |
|
||||
| `unit` | string | 否 | 最大 64 字符 | 单位 |
|
||||
| `userRequirement` | string | 否 | - | 用户需求 |
|
||||
| `designDescription` | string | 否 | - | 设计说明 |
|
||||
| `convertStatus` | string | 否 | `pending` / `success` / `failed` / `partial_failed`,最大 32 字符 | 转换状态 |
|
||||
| `errorMessage` | string | 否 | - | 转换错误信息 |
|
||||
|
||||
同一 `categoryId` 下 `sourceSwFilename` 不能重复;当更新 `categoryId` 或 `sourceSwFilename` 时会触发唯一性校验。
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"modelName": "支架模型 V2",
|
||||
"material": "6061 铝合金",
|
||||
"unit": "mm"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadModel`
|
||||
|
||||
### 2.4 查询模型建模历史列表
|
||||
|
||||
`GET /api/cad-models/{modelId}/histories`
|
||||
|
||||
按 `historyIndex` 升序、ID 升序返回。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadModelHistory[]`
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"modelId": 1,
|
||||
"sourceFeatureId": "Feat-Extrude1",
|
||||
"historyIndex": 0,
|
||||
"historyName": "拉伸",
|
||||
"operationType": "Extrude",
|
||||
"stepPath": "uploads/history/101.step",
|
||||
"glbPath": "uploads/history/101.glb",
|
||||
"topologyPath": "uploads/history/101.topology.json",
|
||||
"convertStatus": "success",
|
||||
"createdAt": "2026-07-08 16:20:00",
|
||||
"updatedAt": "2026-07-08 16:20:00"
|
||||
}
|
||||
],
|
||||
"requestId": "b3f8..."
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 建模历史标注接口
|
||||
|
||||
### 3.1 保存建模历史步骤标注
|
||||
|
||||
`POST /api/cad-models/{modelId}/histories/{historyId}/annotation`
|
||||
|
||||
同一个建模历史只能有一条标注。重复保存时按 `historyId` 覆盖原有标注内容。请求体可为空;为空时三个文本字段均按 `null` 保存或覆盖。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
| `historyId` | number | 是 | 正整数 | 建模历史 ID,必须归属当前模型 |
|
||||
|
||||
#### 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelingDescription` | string | 否 | - | 建模描述 |
|
||||
| `supplementDescription` | string | 否 | - | 补充描述 |
|
||||
| `remark` | string | 否 | - | 备注 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"modelingDescription": "通过草图轮廓进行基体拉伸",
|
||||
"supplementDescription": "该步骤决定主体厚度",
|
||||
"remark": "前端可作为历史步骤说明展示"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadModelHistoryAnnotation`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 201,
|
||||
"modelId": 1,
|
||||
"historyId": 101,
|
||||
"modelingDescription": "通过草图轮廓进行基体拉伸",
|
||||
"supplementDescription": "该步骤决定主体厚度",
|
||||
"remark": "前端可作为历史步骤说明展示",
|
||||
"createdAt": "2026-07-08 16:20:00",
|
||||
"updatedAt": "2026-07-08 16:20:00"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 结构接口
|
||||
|
||||
### 4.1 创建 CAD 结构
|
||||
|
||||
`POST /api/cad-models/{modelId}/structures`
|
||||
|
||||
创建结构时必须选择至少一个建模历史步骤。结构与历史的关系按 `historyIds` 请求数组顺序保存,`sortOrder` 从 `0` 开始。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
|
||||
#### 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `structureName` | string | 是 | 非空,最大 255 字符 | 结构名称 |
|
||||
| `structureType` | string | 否 | 最大 64 字符 | 结构类型,未传时保存为空字符串 |
|
||||
| `purpose` | string | 否 | 最大 1024 字符 | 结构用途,未传时保存为空字符串 |
|
||||
| `solution` | string | 否 | 最大 1024 字符 | 结构方案,未传时保存为空字符串 |
|
||||
| `reason` | string | 否 | - | 结构原因 |
|
||||
| `remark` | string | 否 | - | 备注 |
|
||||
| `historyIds` | number[] | 是 | 非空、元素为正整数、不能重复、必须都归属当前模型 | 关联建模历史 ID 列表 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"structureName": "主体支撑结构",
|
||||
"structureType": "支撑件",
|
||||
"purpose": "承载上方组件并定位",
|
||||
"solution": "基体拉伸后增加加强筋",
|
||||
"reason": "提升抗弯能力",
|
||||
"remark": "与历史步骤 101、102 关联",
|
||||
"historyIds": [101, 102]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadStructure`
|
||||
|
||||
### 4.2 修改 CAD 结构
|
||||
|
||||
`POST /api/cad-models/{modelId}/structures/{structureId}`
|
||||
|
||||
普通字段支持部分更新,请求未传字段保持原值。请求体可为空;为空时不更新,返回当前结构详情。传入 `historyIds` 时,会按最终数组全量替换结构下的建模历史关系。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
| `structureId` | number | 是 | 正整数 | 结构 ID,必须归属当前模型且未逻辑删除 |
|
||||
|
||||
#### 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `structureName` | string | 否 | 非空,最大 255 字符 | 结构名称 |
|
||||
| `structureType` | string | 否 | 最大 64 字符 | 结构类型 |
|
||||
| `purpose` | string | 否 | 最大 1024 字符 | 结构用途 |
|
||||
| `solution` | string | 否 | 最大 1024 字符 | 结构方案 |
|
||||
| `reason` | string | 否 | - | 结构原因 |
|
||||
| `remark` | string | 否 | - | 备注 |
|
||||
| `historyIds` | number[] | 否 | 非空、元素为正整数、不能重复、必须都归属当前模型 | 传入时全量替换关系;不传时关系不变 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"purpose": "承载上方组件并提供装配定位",
|
||||
"historyIds": [101, 103, 104]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadStructure`
|
||||
|
||||
### 4.3 查询模型下结构列表
|
||||
|
||||
`GET /api/cad-models/{modelId}/structures`
|
||||
|
||||
只返回未逻辑删除的结构,按结构 ID 升序返回,并携带已关联的建模历史步骤。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadStructure[]`
|
||||
|
||||
### 4.4 查询结构详情
|
||||
|
||||
`GET /api/cad-models/{modelId}/structures/{structureId}`
|
||||
|
||||
用于结构编辑回显,返回结构基础信息和已关联的建模历史步骤。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
| `structureId` | number | 是 | 正整数 | 结构 ID,必须归属当前模型且未逻辑删除 |
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadStructure`
|
||||
|
||||
### 4.5 删除 CAD 结构
|
||||
|
||||
`DELETE /api/cad-models/{modelId}/structures/{structureId}`
|
||||
|
||||
按模型归属校验后逻辑删除结构。删除后该结构不会再出现在结构列表和结构详情中;后端会同步清理该结构关联的建模历史关系,以及功能中引用该结构的关系。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
| `structureId` | number | 是 | 正整数 | 结构 ID,必须归属当前模型且未逻辑删除 |
|
||||
|
||||
#### 请求体
|
||||
|
||||
无。
|
||||
|
||||
#### 响应 data
|
||||
|
||||
无。由于响应字段全局忽略 `null`,成功时通常不返回 `data` 字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "success",
|
||||
"requestId": "b3f8..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 失败说明
|
||||
|
||||
| HTTP 状态码 | code | 场景 |
|
||||
| --- | --- | --- |
|
||||
| 404 | `NOT_FOUND` | 模型不存在,或结构不存在、已删除、不是当前模型下的结构 |
|
||||
| 422 | `INVALID_REQUEST` | `modelId` 或 `structureId` 不是正整数 |
|
||||
|
||||
### 4.6 CadStructure 响应字段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 301,
|
||||
"modelId": 1,
|
||||
"structureName": "主体支撑结构",
|
||||
"structureType": "支撑件",
|
||||
"purpose": "承载上方组件并定位",
|
||||
"solution": "基体拉伸后增加加强筋",
|
||||
"reason": "提升抗弯能力",
|
||||
"remark": "与历史步骤 101、102 关联",
|
||||
"historyItems": [
|
||||
{
|
||||
"relationId": 401,
|
||||
"historyId": 101,
|
||||
"historyIndex": 0,
|
||||
"historyName": "拉伸",
|
||||
"operationType": "Extrude",
|
||||
"sortOrder": 0
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-07-08 16:20:00",
|
||||
"updatedAt": "2026-07-08 16:20:00"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | number | 结构 ID |
|
||||
| `modelId` | number | 模型 ID |
|
||||
| `structureName` | string | 结构名称 |
|
||||
| `structureType` | string | 结构类型 |
|
||||
| `purpose` | string | 结构用途 |
|
||||
| `solution` | string | 结构方案 |
|
||||
| `reason` | string | 结构原因 |
|
||||
| `remark` | string | 备注 |
|
||||
| `historyItems` | array | 关联建模历史步骤,按 `sortOrder` 升序 |
|
||||
| `createdAt` | string | 创建时间 |
|
||||
| `updatedAt` | string | 更新时间 |
|
||||
|
||||
`historyItems` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `relationId` | number | 结构-历史关系 ID |
|
||||
| `historyId` | number | 建模历史 ID |
|
||||
| `historyIndex` | number | 建模历史顺序号 |
|
||||
| `historyName` | string | 建模历史名称 |
|
||||
| `operationType` | string | 操作类型 |
|
||||
| `sortOrder` | number | 关系排序,按请求数组顺序生成 |
|
||||
|
||||
## 5. 功能接口
|
||||
|
||||
### 5.1 创建 CAD 功能
|
||||
|
||||
`POST /api/cad-models/{modelId}/functions`
|
||||
|
||||
创建功能时必须选择至少一个结构。功能与结构的关系按 `structureIds` 请求数组顺序保存,`sortOrder` 从 `0` 开始。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
|
||||
#### 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `functionName` | string | 是 | 非空,最大 255 字符 | 功能名称 |
|
||||
| `functionType` | string | 否 | 最大 64 字符 | 功能类型,未传时保存为空字符串 |
|
||||
| `goalDescription` | string | 否 | - | 功能目标描述 |
|
||||
| `remark` | string | 否 | - | 备注 |
|
||||
| `structureIds` | number[] | 是 | 非空、元素为正整数、不能重复、必须都归属当前模型且未逻辑删除 | 关联结构 ID 列表 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"functionName": "承载与定位",
|
||||
"functionType": "机械功能",
|
||||
"goalDescription": "支撑外部载荷并限制装配自由度",
|
||||
"remark": "由主体支撑结构和定位孔结构共同实现",
|
||||
"structureIds": [301, 302]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadFunction`
|
||||
|
||||
### 5.2 修改 CAD 功能
|
||||
|
||||
`POST /api/cad-models/{modelId}/functions/{functionId}`
|
||||
|
||||
普通字段支持部分更新,请求未传字段保持原值。请求体可为空;为空时不更新,返回当前功能详情。传入 `structureIds` 时,会按最终数组全量替换功能下的结构关系。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
| `functionId` | number | 是 | 正整数 | 功能 ID,必须归属当前模型且未逻辑删除 |
|
||||
|
||||
#### 请求体
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `functionName` | string | 否 | 非空,最大 255 字符 | 功能名称 |
|
||||
| `functionType` | string | 否 | 最大 64 字符 | 功能类型 |
|
||||
| `goalDescription` | string | 否 | - | 功能目标描述 |
|
||||
| `remark` | string | 否 | - | 备注 |
|
||||
| `structureIds` | number[] | 否 | 非空、元素为正整数、不能重复、必须都归属当前模型且未逻辑删除 | 传入时全量替换关系;不传时关系不变 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"goalDescription": "支撑外部载荷、定位并辅助装配",
|
||||
"structureIds": [302, 301]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadFunction`
|
||||
|
||||
### 5.3 查询模型下功能列表
|
||||
|
||||
`GET /api/cad-models/{modelId}/functions`
|
||||
|
||||
只返回未逻辑删除的功能,按功能 ID 升序返回,并携带已关联的结构。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadFunction[]`
|
||||
|
||||
### 5.4 查询功能详情
|
||||
|
||||
`GET /api/cad-models/{modelId}/functions/{functionId}`
|
||||
|
||||
用于功能编辑回显,返回功能基础信息和已关联的结构。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
| `functionId` | number | 是 | 正整数 | 功能 ID,必须归属当前模型且未逻辑删除 |
|
||||
|
||||
#### 响应 data
|
||||
|
||||
`CadFunction`
|
||||
|
||||
### 5.5 删除 CAD 功能
|
||||
|
||||
`DELETE /api/cad-models/{modelId}/functions/{functionId}`
|
||||
|
||||
按模型归属校验后逻辑删除功能。删除后该功能不会再出现在功能列表和功能详情中;后端会同步清理该功能下的结构关系。
|
||||
|
||||
#### Path 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 约束 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `modelId` | number | 是 | 正整数 | 模型 ID |
|
||||
| `functionId` | number | 是 | 正整数 | 功能 ID,必须归属当前模型且未逻辑删除 |
|
||||
|
||||
#### 请求体
|
||||
|
||||
无。
|
||||
|
||||
#### 响应 data
|
||||
|
||||
无。由于响应字段全局忽略 `null`,成功时通常不返回 `data` 字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "success",
|
||||
"requestId": "b3f8..."
|
||||
}
|
||||
```
|
||||
|
||||
#### 失败说明
|
||||
|
||||
| HTTP 状态码 | code | 场景 |
|
||||
| --- | --- | --- |
|
||||
| 404 | `NOT_FOUND` | 模型不存在,或功能不存在、已删除、不是当前模型下的功能 |
|
||||
| 422 | `INVALID_REQUEST` | `modelId` 或 `functionId` 不是正整数 |
|
||||
|
||||
### 5.6 CadFunction 响应字段
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 501,
|
||||
"modelId": 1,
|
||||
"functionName": "承载与定位",
|
||||
"functionType": "机械功能",
|
||||
"goalDescription": "支撑外部载荷并限制装配自由度",
|
||||
"remark": "由主体支撑结构和定位孔结构共同实现",
|
||||
"structureItems": [
|
||||
{
|
||||
"relationId": 601,
|
||||
"structureId": 301,
|
||||
"structureName": "主体支撑结构",
|
||||
"structureType": "支撑件",
|
||||
"sortOrder": 0
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-07-08 16:20:00",
|
||||
"updatedAt": "2026-07-08 16:20:00"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | number | 功能 ID |
|
||||
| `modelId` | number | 模型 ID |
|
||||
| `functionName` | string | 功能名称 |
|
||||
| `functionType` | string | 功能类型 |
|
||||
| `goalDescription` | string | 功能目标描述 |
|
||||
| `remark` | string | 备注 |
|
||||
| `structureItems` | array | 关联结构,按 `sortOrder` 升序 |
|
||||
| `createdAt` | string | 创建时间 |
|
||||
| `updatedAt` | string | 更新时间 |
|
||||
|
||||
`structureItems` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `relationId` | number | 功能-结构关系 ID |
|
||||
| `structureId` | number | 结构 ID |
|
||||
| `structureName` | string | 结构名称 |
|
||||
| `structureType` | string | 结构类型 |
|
||||
| `sortOrder` | number | 关系排序,按请求数组顺序生成 |
|
||||
|
||||
## 6. 公共数据结构
|
||||
|
||||
### 6.1 CadModel
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | number | 模型 ID |
|
||||
| `categoryId` | number | 分类 ID |
|
||||
| `modelName` | string | 模型名称 |
|
||||
| `sourceSwFilename` | string | 源 SolidWorks 文件名 |
|
||||
| `sourceSwPath` | string | 源文件路径 |
|
||||
| `featureTreeJsonPath` | string | 特征树 JSON 路径 |
|
||||
| `viewPath` | string | 预览文件路径 |
|
||||
| `applicationScenario` | string | 应用场景 |
|
||||
| `material` | string | 材料 |
|
||||
| `manufacturingMethod` | string | 制造方式 |
|
||||
| `unit` | string | 单位 |
|
||||
| `userRequirement` | string | 用户需求 |
|
||||
| `designDescription` | string | 设计说明 |
|
||||
| `convertStatus` | string | 转换状态:`pending` / `success` / `failed` / `partial_failed` |
|
||||
| `errorMessage` | string | 转换错误信息 |
|
||||
| `createdAt` | string | 创建时间 |
|
||||
| `updatedAt` | string | 更新时间 |
|
||||
|
||||
### 6.2 CadModelHistory
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | number | 建模历史 ID |
|
||||
| `modelId` | number | 模型 ID |
|
||||
| `sourceFeatureId` | string | 源特征 ID |
|
||||
| `historyIndex` | number | 历史步骤顺序号 |
|
||||
| `historyName` | string | 历史步骤名称 |
|
||||
| `operationType` | string | 操作类型 |
|
||||
| `stepPath` | string | STEP 文件路径 |
|
||||
| `glbPath` | string | GLB 文件路径 |
|
||||
| `topologyPath` | string | 拓扑文件路径 |
|
||||
| `convertStatus` | string | 转换状态 |
|
||||
| `errorMessage` | string | 转换错误信息 |
|
||||
| `createdAt` | string | 创建时间 |
|
||||
| `updatedAt` | string | 更新时间 |
|
||||
|
||||
### 6.3 CadModelHistoryAnnotation
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | number | 标注 ID |
|
||||
| `modelId` | number | 模型 ID |
|
||||
| `historyId` | number | 建模历史 ID |
|
||||
| `modelingDescription` | string | 建模描述 |
|
||||
| `supplementDescription` | string | 补充描述 |
|
||||
| `remark` | string | 备注 |
|
||||
| `createdAt` | string | 创建时间 |
|
||||
| `updatedAt` | string | 更新时间 |
|
||||
|
||||
## 7. 前端联调注意事项
|
||||
|
||||
1. 所有 Path ID 都必须是正整数。
|
||||
2. `POST` 更新类接口采用部分更新语义;字段未传表示不修改,字段传空字符串表示按空字符串更新。
|
||||
3. 创建结构/功能时,关联 ID 数组必传且不能为空;更新结构/功能时,只有传入关联 ID 数组才会全量替换关系。
|
||||
4. `historyIds` 和 `structureIds` 不能包含重复值;数组顺序会影响响应中的 `sortOrder`。
|
||||
5. 结构、功能列表接口只返回未逻辑删除的数据。
|
||||
6. 前端排查问题时可记录响应中的 `requestId`,便于后端按请求链路定位日志。
|
||||
@@ -0,0 +1,125 @@
|
||||
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.4</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.cadlabel</groupId>
|
||||
<artifactId>cadlabel</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>cadlabel</name>
|
||||
<description>CadLabel backend service.</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<mybatis-plus.version>3.5.11</mybatis-plus.version>
|
||||
<springdoc.version>2.8.6</springdoc.version>
|
||||
<tencentcloud.cos.sdk.version>5.6.246</tencentcloud.cos.sdk.version>
|
||||
<tencentcloud.cos.sts.sdk.version>3.1.1</tencentcloud.cos.sts.sdk.version>
|
||||
</properties>
|
||||
|
||||
<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>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos_api</artifactId>
|
||||
<version>${tencentcloud.cos.sdk.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos-sts_api</artifactId>
|
||||
<version>${tencentcloud.cos.sts.sdk.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</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.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>public</id>
|
||||
<name>aliyun nexus</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
</repository>
|
||||
</repositories>
|
||||
</project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cadlabel;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@ConfigurationPropertiesScan
|
||||
@SpringBootApplication
|
||||
public class CadLabelApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CadLabelApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
public final class ApiCode {
|
||||
|
||||
public static final String OK = "OK";
|
||||
public static final String NOT_FOUND = "NOT_FOUND";
|
||||
public static final String INVALID_REQUEST = "INVALID_REQUEST";
|
||||
public static final String INTERNAL_ERROR = "INTERNAL_ERROR";
|
||||
|
||||
private ApiCode() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private static final String SUCCESS_MESSAGE = "success";
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
private final T data;
|
||||
private final String requestId;
|
||||
|
||||
private ApiResponse(String code, String message, T data, String requestId) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(ApiCode.OK, SUCCESS_MESSAGE, data, RequestIdHolder.get());
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> failure(String code, String message) {
|
||||
return new ApiResponse<>(code, message, null, RequestIdHolder.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PageResponse<T>(
|
||||
List<T> items,
|
||||
long page,
|
||||
long size,
|
||||
long total
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.cadlabel.common.api;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
|
||||
public final class RequestIdHolder {
|
||||
|
||||
private static final String KEY = "requestId";
|
||||
|
||||
private RequestIdHolder() {
|
||||
}
|
||||
|
||||
public static void set(String requestId) {
|
||||
MDC.put(KEY, requestId);
|
||||
}
|
||||
|
||||
public static String get() {
|
||||
return MDC.get(KEY);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
MDC.remove(KEY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cadlabel.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class BizException extends RuntimeException {
|
||||
|
||||
private final int httpStatus;
|
||||
private final String code;
|
||||
|
||||
public BizException(int httpStatus, String code, String message) {
|
||||
super(message);
|
||||
this.httpStatus = httpStatus;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.cadlabel.common.exception;
|
||||
|
||||
import com.cadlabel.common.api.ApiCode;
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BizException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBiz(BizException exception) {
|
||||
return ResponseEntity.status(exception.getHttpStatus())
|
||||
.body(ApiResponse.failure(exception.getCode(), exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MethodArgumentNotValidException.class,
|
||||
BindException.class,
|
||||
ConstraintViolationException.class,
|
||||
HttpMediaTypeNotSupportedException.class,
|
||||
HttpRequestMethodNotSupportedException.class,
|
||||
IllegalArgumentException.class
|
||||
})
|
||||
public ResponseEntity<ApiResponse<Void>> handleBadRequest(Exception exception) {
|
||||
return ResponseEntity.unprocessableEntity()
|
||||
.body(ApiResponse.failure(ApiCode.INVALID_REQUEST, exception.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(MaxUploadSizeExceededException exception) {
|
||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||
.body(ApiResponse.failure(ApiCode.INVALID_REQUEST, "上传文件超过大小限制"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataAccessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleDataAccess(DataAccessException exception, HttpServletRequest request) {
|
||||
log.error("Database exception at {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.failure(ApiCode.INTERNAL_ERROR, "数据库异常"));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
NoResourceFoundException.class,
|
||||
NoHandlerFoundException.class
|
||||
})
|
||||
public ResponseEntity<ApiResponse<Void>> handleNotFound(HttpServletRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.failure(ApiCode.NOT_FOUND, "请求路径不存在: %s %s"
|
||||
.formatted(request.getMethod(), request.getRequestURI())));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception exception, HttpServletRequest request) {
|
||||
log.error("Unhandled exception at {} {}", request.getMethod(), request.getRequestURI(), exception);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.failure(ApiCode.INTERNAL_ERROR, "系统异常"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.cadlabel.common.web;
|
||||
|
||||
import com.cadlabel.common.api.RequestIdHolder;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class RequestIdFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String requestId = Optional.ofNullable(request.getHeader("X-Request-Id"))
|
||||
.filter(value -> !value.isBlank())
|
||||
.orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));
|
||||
RequestIdHolder.set(requestId);
|
||||
response.setHeader("X-Request-Id", requestId);
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
RequestIdHolder.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.cadlabel.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 跨域配置类
|
||||
*
|
||||
* @author robotquan
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
/**
|
||||
* 跨域配置
|
||||
*/
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
// 设置允许跨域的路径
|
||||
registry.addMapping("/**")
|
||||
// 设置允许跨域请求的域名
|
||||
.allowedOriginPatterns("*")
|
||||
// 是否允许cookie
|
||||
.allowCredentials(true)
|
||||
// 设置允许的请求方式
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
// 设置允许的header属性
|
||||
.allowedHeaders("*")
|
||||
// 跨域允许时间
|
||||
.maxAge(3600);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨域过滤器
|
||||
*/
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
// 允许所有域名进行跨域调用
|
||||
config.addAllowedOriginPattern("*");
|
||||
// 允许所有请求头
|
||||
config.addAllowedHeader("*");
|
||||
// 允许所有请求方法
|
||||
config.addAllowedMethod("*");
|
||||
// 允许发送Cookie信息
|
||||
config.setAllowCredentials(true);
|
||||
// 预检请求的缓存时间(秒)
|
||||
config.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
// 对所有路径应用跨域配置
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
|
||||
return new CorsFilter(source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cadlabel.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.cos")
|
||||
public record CosProperties(
|
||||
String secretId,
|
||||
String secretKey,
|
||||
String bucket,
|
||||
String region,
|
||||
String publicBaseUrl,
|
||||
Duration uploadSessionTtl,
|
||||
String uploadPrefix,
|
||||
long maxUploadSizeBytes
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.cadlabel.config;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
|
||||
private static final ZoneId ZONE_ID = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
|
||||
return builder -> {
|
||||
SimpleModule module = new SimpleModule();
|
||||
module.addSerializer(LocalDateTime.class, new LocalDateTimeJsonSerializer());
|
||||
module.addDeserializer(LocalDateTime.class, new LocalDateTimeJsonDeserializer());
|
||||
module.addSerializer(Instant.class, new InstantJsonSerializer());
|
||||
module.addDeserializer(Instant.class, new InstantJsonDeserializer());
|
||||
builder.modules(module);
|
||||
builder.simpleDateFormat(DATE_TIME_PATTERN);
|
||||
builder.timeZone(ZONE_ID.getId());
|
||||
};
|
||||
}
|
||||
|
||||
private static class LocalDateTimeJsonSerializer extends JsonSerializer<LocalDateTime> {
|
||||
|
||||
@Override
|
||||
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeString(value.format(DATE_TIME_FORMATTER));
|
||||
}
|
||||
}
|
||||
|
||||
private static class LocalDateTimeJsonDeserializer extends JsonDeserializer<LocalDateTime> {
|
||||
|
||||
@Override
|
||||
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
return LocalDateTime.parse(p.getValueAsString(), DATE_TIME_FORMATTER);
|
||||
}
|
||||
}
|
||||
|
||||
private static class InstantJsonSerializer extends JsonSerializer<Instant> {
|
||||
|
||||
@Override
|
||||
public void serialize(Instant value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeString(DATE_TIME_FORMATTER.format(value.atZone(ZONE_ID).toLocalDateTime()));
|
||||
}
|
||||
}
|
||||
|
||||
private static class InstantJsonDeserializer extends JsonDeserializer<Instant> {
|
||||
|
||||
@Override
|
||||
public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
return LocalDateTime.parse(p.getValueAsString(), DATE_TIME_FORMATTER).atZone(ZONE_ID).toInstant();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.cadlabel.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import java.time.LocalDateTime;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MetaObjectHandler metaObjectHandler() {
|
||||
return new MetaObjectHandler() {
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
|
||||
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadStructureResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
|
||||
import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
|
||||
import com.cadlabel.service.CadAnnotationService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/cad-models/{modelId}")
|
||||
@RequiredArgsConstructor
|
||||
public class CadAnnotationController {
|
||||
|
||||
private final CadAnnotationService cadAnnotationService;
|
||||
|
||||
/**
|
||||
* 保存建模历史步骤标注。
|
||||
*
|
||||
* <p>同一个建模历史只能有一条标注;重复保存时按 historyId 覆盖原有标注内容。</p>
|
||||
*/
|
||||
@PostMapping("/histories/{historyId}/annotation")
|
||||
public ApiResponse<CadModelHistoryAnnotationResponse> saveHistoryAnnotation(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@PathVariable @Positive Long historyId,
|
||||
@Valid @RequestBody(required = false) SaveHistoryAnnotationRequest request
|
||||
) {
|
||||
return ApiResponse.success(cadAnnotationService.saveHistoryAnnotation(modelId, historyId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 CAD 结构。
|
||||
*
|
||||
* <p>创建时必须选择至少一个建模历史步骤,关系排序按 historyIds 请求顺序生成。</p>
|
||||
*/
|
||||
@PostMapping("/structures")
|
||||
public ApiResponse<CadStructureResponse> createStructure(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@Valid @RequestBody CreateCadStructureRequest request
|
||||
) {
|
||||
return ApiResponse.success(cadAnnotationService.createStructure(modelId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 CAD 结构。
|
||||
*
|
||||
* <p>普通字段支持部分更新;传入 historyIds 时按最终集合全量替换结构下的建模历史关系。</p>
|
||||
*/
|
||||
@PostMapping("/structures/{structureId}")
|
||||
public ApiResponse<CadStructureResponse> updateStructure(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@PathVariable @Positive Long structureId,
|
||||
@Valid @RequestBody(required = false) UpdateCadStructureRequest request
|
||||
) {
|
||||
return ApiResponse.success(cadAnnotationService.updateStructure(modelId, structureId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模型下的结构列表。
|
||||
*
|
||||
* <p>只返回未逻辑删除的结构,并携带结构关联的建模历史步骤。</p>
|
||||
*/
|
||||
@GetMapping("/structures")
|
||||
public ApiResponse<List<CadStructureResponse>> listStructures(@PathVariable @Positive Long modelId) {
|
||||
return ApiResponse.success(cadAnnotationService.listStructures(modelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询结构详情。
|
||||
*
|
||||
* <p>用于结构编辑回显,返回结构基础信息和已关联的建模历史步骤。</p>
|
||||
*/
|
||||
@GetMapping("/structures/{structureId}")
|
||||
public ApiResponse<CadStructureResponse> getStructure(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@PathVariable @Positive Long structureId
|
||||
) {
|
||||
return ApiResponse.success(cadAnnotationService.getStructure(modelId, structureId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 CAD 结构。
|
||||
*
|
||||
* <p>按模型归属校验后逻辑删除结构,并清理结构下的关联关系。</p>
|
||||
*/
|
||||
@DeleteMapping("/structures/{structureId}")
|
||||
public ApiResponse<Void> deleteStructure(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@PathVariable @Positive Long structureId
|
||||
) {
|
||||
cadAnnotationService.deleteStructure(modelId, structureId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 CAD 功能。
|
||||
*
|
||||
* <p>创建时必须选择至少一个结构,关系排序按 structureIds 请求顺序生成。</p>
|
||||
*/
|
||||
@PostMapping("/functions")
|
||||
public ApiResponse<CadFunctionResponse> createFunction(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@Valid @RequestBody CreateCadFunctionRequest request
|
||||
) {
|
||||
return ApiResponse.success(cadAnnotationService.createFunction(modelId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 CAD 功能。
|
||||
*
|
||||
* <p>普通字段支持部分更新;传入 structureIds 时按最终集合全量替换功能下的结构关系。</p>
|
||||
*/
|
||||
@PostMapping("/functions/{functionId}")
|
||||
public ApiResponse<CadFunctionResponse> updateFunction(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@PathVariable @Positive Long functionId,
|
||||
@Valid @RequestBody(required = false) UpdateCadFunctionRequest request
|
||||
) {
|
||||
return ApiResponse.success(cadAnnotationService.updateFunction(modelId, functionId, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模型下的功能列表。
|
||||
*
|
||||
* <p>只返回未逻辑删除的功能,并携带功能关联的结构。</p>
|
||||
*/
|
||||
@GetMapping("/functions")
|
||||
public ApiResponse<List<CadFunctionResponse>> listFunctions(@PathVariable @Positive Long modelId) {
|
||||
return ApiResponse.success(cadAnnotationService.listFunctions(modelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询功能详情。
|
||||
*
|
||||
* <p>用于功能编辑回显,返回功能基础信息和已关联的结构。</p>
|
||||
*/
|
||||
@GetMapping("/functions/{functionId}")
|
||||
public ApiResponse<CadFunctionResponse> getFunction(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@PathVariable @Positive Long functionId
|
||||
) {
|
||||
return ApiResponse.success(cadAnnotationService.getFunction(modelId, functionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 CAD 功能。
|
||||
*
|
||||
* <p>按模型归属校验后逻辑删除功能,并清理功能下的结构关系。</p>
|
||||
*/
|
||||
@DeleteMapping("/functions/{functionId}")
|
||||
public ApiResponse<Void> deleteFunction(
|
||||
@PathVariable @Positive Long modelId,
|
||||
@PathVariable @Positive Long functionId
|
||||
) {
|
||||
cadAnnotationService.deleteFunction(modelId, functionId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import com.cadlabel.common.api.PageResponse;
|
||||
import com.cadlabel.dto.cad.CadModelHistoryResponse;
|
||||
import com.cadlabel.dto.cad.CadModelListQuery;
|
||||
import com.cadlabel.dto.cad.CadModelResponse;
|
||||
import com.cadlabel.dto.cad.UpdateCadModelRequest;
|
||||
import com.cadlabel.service.CadModelService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/cad-models")
|
||||
@RequiredArgsConstructor
|
||||
public class CadModelController {
|
||||
|
||||
private final CadModelService cadModelService;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResponse<CadModelResponse>> listModels(
|
||||
@RequestParam(defaultValue = "1") @Min(1) int page,
|
||||
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
|
||||
@RequestParam(required = false) @Positive Long categoryId,
|
||||
@RequestParam(required = false) String convertStatus,
|
||||
@RequestParam(required = false) String keyword
|
||||
) {
|
||||
return ApiResponse.success(cadModelService.listModels(
|
||||
new CadModelListQuery(page, size, categoryId, convertStatus, keyword)
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<CadModelResponse> getModel(@PathVariable @Positive Long id) {
|
||||
return ApiResponse.success(cadModelService.getModel(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}")
|
||||
public ApiResponse<CadModelResponse> updateModel(
|
||||
@PathVariable @Positive Long id,
|
||||
@Valid @RequestBody(required = false) UpdateCadModelRequest request
|
||||
) {
|
||||
return ApiResponse.success(cadModelService.updateModel(id, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{modelId}/histories")
|
||||
public ApiResponse<List<CadModelHistoryResponse>> listHistories(@PathVariable @Positive Long modelId) {
|
||||
return ApiResponse.success(cadModelService.listHistories(modelId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import com.cadlabel.service.CosService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/cos")
|
||||
@RequiredArgsConstructor
|
||||
public class CosController {
|
||||
|
||||
private final CosService cosService;
|
||||
|
||||
@PostMapping("/upload-sessions")
|
||||
public ApiResponse<PrepareCosUploadSessionResponse> prepareUploadSession(
|
||||
@Valid @RequestBody(required = false) PrepareCosUploadSessionRequest request
|
||||
) {
|
||||
return ApiResponse.success(cosService.prepareUploadSession(request));
|
||||
}
|
||||
|
||||
@PostMapping("/objects")
|
||||
public ApiResponse<CosUploadObjectResponse> uploadObject(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(required = false) String directory
|
||||
) {
|
||||
return ApiResponse.success(cosService.uploadObject(file, directory));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cadlabel.dto.cad;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record CadModelHistoryResponse(
|
||||
Long id,
|
||||
Long modelId,
|
||||
String sourceFeatureId,
|
||||
Integer historyIndex,
|
||||
String historyName,
|
||||
String operationType,
|
||||
String stepPath,
|
||||
String glbPath,
|
||||
String topologyPath,
|
||||
String convertStatus,
|
||||
String errorMessage,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cadlabel.dto.cad;
|
||||
|
||||
public record CadModelListQuery(
|
||||
int page,
|
||||
int size,
|
||||
Long categoryId,
|
||||
String convertStatus,
|
||||
String keyword
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cadlabel.dto.cad;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record CadModelResponse(
|
||||
Long id,
|
||||
Long categoryId,
|
||||
String modelName,
|
||||
String sourceSwFilename,
|
||||
String sourceSwPath,
|
||||
String featureTreeJsonPath,
|
||||
String viewPath,
|
||||
String applicationScenario,
|
||||
String material,
|
||||
String manufacturingMethod,
|
||||
String unit,
|
||||
String userRequirement,
|
||||
String designDescription,
|
||||
String convertStatus,
|
||||
String errorMessage,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cadlabel.dto.cad;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record UpdateCadModelRequest(
|
||||
@Positive Long categoryId,
|
||||
@Size(max = 255) String modelName,
|
||||
@Size(max = 255) String sourceSwFilename,
|
||||
@Size(max = 1024) String sourceSwPath,
|
||||
@Size(max = 1024) String featureTreeJsonPath,
|
||||
@Size(max = 1024) String viewPath,
|
||||
@Size(max = 255) String applicationScenario,
|
||||
@Size(max = 255) String material,
|
||||
@Size(max = 255) String manufacturingMethod,
|
||||
@Size(max = 64) String unit,
|
||||
String userRequirement,
|
||||
String designDescription,
|
||||
@Size(max = 32) String convertStatus,
|
||||
String errorMessage
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record CadFunctionResponse(
|
||||
Long id,
|
||||
Long modelId,
|
||||
String functionName,
|
||||
String functionType,
|
||||
String goalDescription,
|
||||
String remark,
|
||||
List<CadFunctionStructureItemResponse> structureItems,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
public record CadFunctionStructureItemResponse(
|
||||
Long relationId,
|
||||
Long structureId,
|
||||
String structureName,
|
||||
String structureType,
|
||||
Integer sortOrder
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record CadModelHistoryAnnotationResponse(
|
||||
Long id,
|
||||
Long modelId,
|
||||
Long historyId,
|
||||
String modelingDescription,
|
||||
String supplementDescription,
|
||||
String remark,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
public record CadStructureHistoryItemResponse(
|
||||
Long relationId,
|
||||
Long historyId,
|
||||
Integer historyIndex,
|
||||
String historyName,
|
||||
String operationType,
|
||||
Integer sortOrder
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public record CadStructureResponse(
|
||||
Long id,
|
||||
Long modelId,
|
||||
String structureName,
|
||||
String structureType,
|
||||
String purpose,
|
||||
String solution,
|
||||
String reason,
|
||||
String remark,
|
||||
List<CadStructureHistoryItemResponse> historyItems,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
public record CreateCadFunctionRequest(
|
||||
@NotBlank @Size(max = 255) String functionName,
|
||||
@Size(max = 64) String functionType,
|
||||
String goalDescription,
|
||||
String remark,
|
||||
@NotEmpty List<@Positive Long> structureIds
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
public record CreateCadStructureRequest(
|
||||
@NotBlank @Size(max = 255) String structureName,
|
||||
@Size(max = 64) String structureType,
|
||||
@Size(max = 1024) String purpose,
|
||||
@Size(max = 1024) String solution,
|
||||
String reason,
|
||||
String remark,
|
||||
@NotEmpty List<@Positive Long> historyIds
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
public record SaveHistoryAnnotationRequest(
|
||||
String modelingDescription,
|
||||
String supplementDescription,
|
||||
String remark
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
public record UpdateCadFunctionRequest(
|
||||
@Size(max = 255) String functionName,
|
||||
@Size(max = 64) String functionType,
|
||||
String goalDescription,
|
||||
String remark,
|
||||
List<@Positive Long> structureIds
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cadlabel.dto.cad.annotation;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
public record UpdateCadStructureRequest(
|
||||
@Size(max = 255) String structureName,
|
||||
@Size(max = 64) String structureType,
|
||||
@Size(max = 1024) String purpose,
|
||||
@Size(max = 1024) String solution,
|
||||
String reason,
|
||||
String remark,
|
||||
List<@Positive Long> historyIds
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
public record CosTemporaryCredentialsResponse(
|
||||
String tmpSecretId,
|
||||
String tmpSecretKey,
|
||||
String sessionToken,
|
||||
long startTime,
|
||||
long expiredTime
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
public record CosUploadObjectResponse(
|
||||
String bucket,
|
||||
String region,
|
||||
String objectKey,
|
||||
String publicUrl,
|
||||
long size,
|
||||
String contentType
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public record PrepareCosUploadSessionRequest(
|
||||
String directory,
|
||||
Duration duration
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.dto.cos;
|
||||
|
||||
public record PrepareCosUploadSessionResponse(
|
||||
String bucket,
|
||||
String region,
|
||||
String keyPrefix,
|
||||
CosTemporaryCredentialsResponse credentials
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.cadlabel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("cad_function")
|
||||
public class CadFunction {
|
||||
|
||||
/** 功能ID。 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 关联 cad_model.id。 */
|
||||
private Long modelId;
|
||||
|
||||
/** 功能名称。 */
|
||||
private String functionName;
|
||||
|
||||
/** 功能类型。 */
|
||||
private String functionType;
|
||||
|
||||
/** 功能目标或说明。 */
|
||||
private String goalDescription;
|
||||
|
||||
/** 备注。 */
|
||||
private String remark;
|
||||
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** 逻辑删除标记:0 未删除,1 已删除。 */
|
||||
private Integer deleted;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.cadlabel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("cad_function_structure_rel")
|
||||
public class CadFunctionStructureRel {
|
||||
|
||||
/** 功能-结构关系ID。 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 关联 cad_model.id。 */
|
||||
private Long modelId;
|
||||
|
||||
/** 关联 cad_function.id。 */
|
||||
private Long functionId;
|
||||
|
||||
/** 关联 cad_structure.id。 */
|
||||
private Long structureId;
|
||||
|
||||
/** 功能内结构排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.cadlabel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("cad_model")
|
||||
public class CadModel {
|
||||
|
||||
/** 模型ID。 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 分类ID。 */
|
||||
private Long categoryId;
|
||||
|
||||
/** 模型名称。 */
|
||||
private String modelName;
|
||||
|
||||
/** 源 SolidWorks 文件名。 */
|
||||
private String sourceSwFilename;
|
||||
|
||||
/** 源 SolidWorks 文件存储路径。 */
|
||||
private String sourceSwPath;
|
||||
|
||||
/** 特征树 JSON 文件路径。 */
|
||||
private String featureTreeJsonPath;
|
||||
|
||||
/** 模型视图资源路径。 */
|
||||
private String viewPath;
|
||||
|
||||
/** 应用场景。 */
|
||||
private String applicationScenario;
|
||||
|
||||
/** 材料。 */
|
||||
private String material;
|
||||
|
||||
/** 制造方式。 */
|
||||
private String manufacturingMethod;
|
||||
|
||||
/** 单位。 */
|
||||
private String unit;
|
||||
|
||||
/** 用户需求描述。 */
|
||||
private String userRequirement;
|
||||
|
||||
/** 整体设计说明。 */
|
||||
private String designDescription;
|
||||
|
||||
/** 转换状态。 */
|
||||
private String convertStatus;
|
||||
|
||||
/** 转换或处理错误信息。 */
|
||||
private String errorMessage;
|
||||
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.cadlabel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("cad_model_history")
|
||||
public class CadModelHistory {
|
||||
|
||||
/** 建模历史ID。 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 关联 cad_model.id。 */
|
||||
private Long modelId;
|
||||
|
||||
/** 源特征ID。 */
|
||||
private String sourceFeatureId;
|
||||
|
||||
/** 建模历史步骤序号。 */
|
||||
private Integer historyIndex;
|
||||
|
||||
/** 建模历史步骤名称。 */
|
||||
private String historyName;
|
||||
|
||||
/** 操作类型。 */
|
||||
private String operationType;
|
||||
|
||||
/** STEP 文件路径。 */
|
||||
private String stepPath;
|
||||
|
||||
/** GLB 文件路径。 */
|
||||
private String glbPath;
|
||||
|
||||
/** 拓扑文件路径。 */
|
||||
private String topologyPath;
|
||||
|
||||
/** 转换状态。 */
|
||||
private String convertStatus;
|
||||
|
||||
/** 转换或处理错误信息。 */
|
||||
private String errorMessage;
|
||||
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.cadlabel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("cad_model_history_annotation")
|
||||
public class CadModelHistoryAnnotation {
|
||||
|
||||
/** 步骤标注ID。 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 关联 cad_model.id。 */
|
||||
private Long modelId;
|
||||
|
||||
/** 关联 cad_model_history.id。 */
|
||||
private Long historyId;
|
||||
|
||||
/** 建模说明:这个 CAD 操作自身做了什么。 */
|
||||
private String modelingDescription;
|
||||
|
||||
/** 补充描述:几何范围、解析异常、与前后步骤关系。 */
|
||||
private String supplementDescription;
|
||||
|
||||
/** 备注:人工复核说明、异常说明。 */
|
||||
private String remark;
|
||||
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.cadlabel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("cad_structure")
|
||||
public class CadStructure {
|
||||
|
||||
/** 结构ID。 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 关联 cad_model.id。 */
|
||||
private Long modelId;
|
||||
|
||||
/** 结构名称。 */
|
||||
private String structureName;
|
||||
|
||||
/** 结构类型。 */
|
||||
private String structureType;
|
||||
|
||||
/** 目的。 */
|
||||
private String purpose;
|
||||
|
||||
/** 方案。 */
|
||||
private String solution;
|
||||
|
||||
/** 原因。 */
|
||||
private String reason;
|
||||
|
||||
/** 备注。 */
|
||||
private String remark;
|
||||
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** 逻辑删除标记:0 未删除,1 已删除。 */
|
||||
private Integer deleted;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.cadlabel.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("cad_structure_history_rel")
|
||||
public class CadStructureHistoryRel {
|
||||
|
||||
/** 结构-步骤关系ID。 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 关联 cad_model.id。 */
|
||||
private Long modelId;
|
||||
|
||||
/** 关联 cad_structure.id。 */
|
||||
private Long structureId;
|
||||
|
||||
/** 关联 cad_model_history.id。 */
|
||||
private Long historyId;
|
||||
|
||||
/** 结构内步骤排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 创建时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.cadlabel.entity.CadFunction;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CadFunctionMapper extends BaseMapper<CadFunction> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.cadlabel.entity.CadFunctionStructureRel;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CadFunctionStructureRelMapper extends BaseMapper<CadFunctionStructureRel> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.cadlabel.entity.CadModelHistoryAnnotation;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CadModelHistoryAnnotationMapper extends BaseMapper<CadModelHistoryAnnotation> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.cadlabel.entity.CadModelHistory;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CadModelHistoryMapper extends BaseMapper<CadModelHistory> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.cadlabel.entity.CadModel;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CadModelMapper extends BaseMapper<CadModel> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.cadlabel.entity.CadStructureHistoryRel;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CadStructureHistoryRelMapper extends BaseMapper<CadStructureHistoryRel> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cadlabel.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.cadlabel.entity.CadStructure;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CadStructureMapper extends BaseMapper<CadStructure> {
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.cadlabel.service;
|
||||
|
||||
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadStructureResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
|
||||
import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
|
||||
import java.util.List;
|
||||
|
||||
public interface CadAnnotationService {
|
||||
|
||||
/**
|
||||
* 保存建模历史步骤标注。
|
||||
*
|
||||
* <p>如果指定 historyId 已存在标注,则覆盖原有标注文本;否则新建标注记录。</p>
|
||||
*/
|
||||
CadModelHistoryAnnotationResponse saveHistoryAnnotation(
|
||||
Long modelId,
|
||||
Long historyId,
|
||||
SaveHistoryAnnotationRequest request
|
||||
);
|
||||
|
||||
/**
|
||||
* 创建结构并建立结构与建模历史步骤的关系。
|
||||
*/
|
||||
CadStructureResponse createStructure(Long modelId, CreateCadStructureRequest request);
|
||||
|
||||
/**
|
||||
* 修改结构基础信息,并在传入 historyIds 时全量替换结构与建模历史步骤的关系。
|
||||
*/
|
||||
CadStructureResponse updateStructure(Long modelId, Long structureId, UpdateCadStructureRequest request);
|
||||
|
||||
/**
|
||||
* 查询模型下未删除的结构列表。
|
||||
*/
|
||||
List<CadStructureResponse> listStructures(Long modelId);
|
||||
|
||||
/**
|
||||
* 查询结构详情。
|
||||
*/
|
||||
CadStructureResponse getStructure(Long modelId, Long structureId);
|
||||
|
||||
/**
|
||||
* 逻辑删除结构,并清理结构关联关系。
|
||||
*/
|
||||
void deleteStructure(Long modelId, Long structureId);
|
||||
|
||||
/**
|
||||
* 创建功能并建立功能与结构的关系。
|
||||
*/
|
||||
CadFunctionResponse createFunction(Long modelId, CreateCadFunctionRequest request);
|
||||
|
||||
/**
|
||||
* 修改功能基础信息,并在传入 structureIds 时全量替换功能与结构的关系。
|
||||
*/
|
||||
CadFunctionResponse updateFunction(Long modelId, Long functionId, UpdateCadFunctionRequest request);
|
||||
|
||||
/**
|
||||
* 查询模型下未删除的功能列表。
|
||||
*/
|
||||
List<CadFunctionResponse> listFunctions(Long modelId);
|
||||
|
||||
/**
|
||||
* 查询功能详情。
|
||||
*/
|
||||
CadFunctionResponse getFunction(Long modelId, Long functionId);
|
||||
|
||||
/**
|
||||
* 逻辑删除功能,并清理功能关联关系。
|
||||
*/
|
||||
void deleteFunction(Long modelId, Long functionId);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cadlabel.service;
|
||||
|
||||
import com.cadlabel.common.api.PageResponse;
|
||||
import com.cadlabel.dto.cad.CadModelHistoryResponse;
|
||||
import com.cadlabel.dto.cad.CadModelListQuery;
|
||||
import com.cadlabel.dto.cad.CadModelResponse;
|
||||
import com.cadlabel.dto.cad.UpdateCadModelRequest;
|
||||
import java.util.List;
|
||||
|
||||
public interface CadModelService {
|
||||
|
||||
PageResponse<CadModelResponse> listModels(CadModelListQuery query);
|
||||
|
||||
CadModelResponse getModel(Long id);
|
||||
|
||||
CadModelResponse updateModel(Long id, UpdateCadModelRequest request);
|
||||
|
||||
List<CadModelHistoryResponse> listHistories(Long modelId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cadlabel.service;
|
||||
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface CosService {
|
||||
|
||||
PrepareCosUploadSessionResponse prepareUploadSession(PrepareCosUploadSessionRequest request);
|
||||
|
||||
CosUploadObjectResponse uploadObject(MultipartFile file, String directory);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cadlabel.service.client;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
|
||||
public interface CosObjectStorageClient {
|
||||
|
||||
TemporaryCredentials issueTemporaryCredentials(String keyPrefix, Duration duration);
|
||||
|
||||
void putObject(String objectKey, InputStream inputStream, long contentLength, String contentType);
|
||||
|
||||
record TemporaryCredentials(
|
||||
String tmpSecretId,
|
||||
String tmpSecretKey,
|
||||
String sessionToken,
|
||||
long startTime,
|
||||
long expiredTime
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.cadlabel.service.client;
|
||||
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.config.CosProperties;
|
||||
import com.qcloud.cos.COSClient;
|
||||
import com.qcloud.cos.ClientConfig;
|
||||
import com.qcloud.cos.auth.BasicCOSCredentials;
|
||||
import com.qcloud.cos.exception.CosClientException;
|
||||
import com.qcloud.cos.model.ObjectMetadata;
|
||||
import com.qcloud.cos.model.PutObjectRequest;
|
||||
import com.qcloud.cos.region.Region;
|
||||
import com.tencent.cloud.CosStsClient;
|
||||
import com.tencent.cloud.Response;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.TreeMap;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TencentCosObjectStorageClient implements CosObjectStorageClient, DisposableBean {
|
||||
|
||||
private static final String[] ALLOWED_ACTIONS = new String[]{
|
||||
"name/cos:PutObject",
|
||||
"name/cos:InitiateMultipartUpload",
|
||||
"name/cos:ListMultipartUploads",
|
||||
"name/cos:ListParts",
|
||||
"name/cos:UploadPart",
|
||||
"name/cos:CompleteMultipartUpload",
|
||||
"name/cos:AbortMultipartUpload"
|
||||
};
|
||||
|
||||
private final CosProperties cosProperties;
|
||||
private final COSClient cosClient;
|
||||
|
||||
public TencentCosObjectStorageClient(CosProperties cosProperties) {
|
||||
this.cosProperties = cosProperties;
|
||||
this.cosClient = createCosClient(cosProperties);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TemporaryCredentials issueTemporaryCredentials(String keyPrefix, Duration duration) {
|
||||
validateConfiguration();
|
||||
int durationSeconds = validateDuration(duration);
|
||||
TreeMap<String, Object> config = new TreeMap<>();
|
||||
config.put("secretId", cosProperties.secretId());
|
||||
config.put("secretKey", cosProperties.secretKey());
|
||||
config.put("bucket", cosProperties.bucket());
|
||||
config.put("region", cosProperties.region());
|
||||
config.put("durationSeconds", durationSeconds);
|
||||
config.put("allowPrefixes", new String[]{normalizeKeyPrefix(keyPrefix) + "*"});
|
||||
config.put("allowActions", ALLOWED_ACTIONS);
|
||||
try {
|
||||
Response response = CosStsClient.getCredential(config);
|
||||
if (response == null || response.credentials == null
|
||||
|| !StringUtils.hasText(response.credentials.tmpSecretId)
|
||||
|| !StringUtils.hasText(response.credentials.tmpSecretKey)
|
||||
|| !StringUtils.hasText(response.credentials.sessionToken)) {
|
||||
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_UPLOAD_CREDENTIALS_FAILED", "COS 未返回完整的临时上传凭证");
|
||||
}
|
||||
return new TemporaryCredentials(
|
||||
response.credentials.tmpSecretId,
|
||||
response.credentials.tmpSecretKey,
|
||||
response.credentials.sessionToken,
|
||||
response.startTime,
|
||||
response.expiredTime
|
||||
);
|
||||
} catch (IOException exception) {
|
||||
log.error("Failed to issue COS upload credentials. keyPrefix={}", keyPrefix, exception);
|
||||
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_UPLOAD_CREDENTIALS_FAILED",
|
||||
"申请 COS 临时上传凭证失败: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putObject(String objectKey, InputStream inputStream, long contentLength, String contentType) {
|
||||
validateConfiguration();
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentLength(contentLength);
|
||||
if (StringUtils.hasText(contentType)) {
|
||||
metadata.setContentType(contentType);
|
||||
}
|
||||
try {
|
||||
cosClient.putObject(new PutObjectRequest(cosProperties.bucket(), normalizeKeyPrefix(objectKey), inputStream, metadata));
|
||||
} catch (CosClientException exception) {
|
||||
log.error("Failed to upload COS object. objectKey={}", objectKey, exception);
|
||||
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_OBJECT_UPLOAD_FAILED",
|
||||
"上传 COS 对象失败: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
cosClient.shutdown();
|
||||
}
|
||||
|
||||
private static COSClient createCosClient(CosProperties cosProperties) {
|
||||
String region = StringUtils.hasText(cosProperties.region()) ? cosProperties.region().trim() : "ap-beijing";
|
||||
return new COSClient(
|
||||
new BasicCOSCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey())),
|
||||
new ClientConfig(new Region(region))
|
||||
);
|
||||
}
|
||||
|
||||
private void validateConfiguration() {
|
||||
requireText(cosProperties.secretId(), "APP_COS_SECRET_ID");
|
||||
requireText(cosProperties.secretKey(), "APP_COS_SECRET_KEY");
|
||||
requireText(cosProperties.bucket(), "APP_COS_BUCKET");
|
||||
requireText(cosProperties.region(), "APP_COS_REGION");
|
||||
}
|
||||
|
||||
private int validateDuration(Duration duration) {
|
||||
if (duration == null || duration.isZero() || duration.isNegative()) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 上传会话有效期配置非法");
|
||||
}
|
||||
long seconds = duration.toSeconds();
|
||||
if (seconds > Integer.MAX_VALUE) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 上传会话有效期配置过大");
|
||||
}
|
||||
return (int) seconds;
|
||||
}
|
||||
|
||||
private String normalizeKeyPrefix(String keyPrefix) {
|
||||
String normalized = defaultText(keyPrefix).trim().replace("\\", "/");
|
||||
while (normalized.startsWith("/")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void requireText(String value, String envName) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: " + envName);
|
||||
}
|
||||
}
|
||||
|
||||
private static String defaultText(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.cadlabel.common.api.ApiCode;
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadFunctionStructureItemResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadStructureHistoryItemResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadStructureResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
|
||||
import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
|
||||
import com.cadlabel.entity.CadFunction;
|
||||
import com.cadlabel.entity.CadFunctionStructureRel;
|
||||
import com.cadlabel.entity.CadModel;
|
||||
import com.cadlabel.entity.CadModelHistory;
|
||||
import com.cadlabel.entity.CadModelHistoryAnnotation;
|
||||
import com.cadlabel.entity.CadStructure;
|
||||
import com.cadlabel.entity.CadStructureHistoryRel;
|
||||
import com.cadlabel.mapper.CadFunctionMapper;
|
||||
import com.cadlabel.mapper.CadFunctionStructureRelMapper;
|
||||
import com.cadlabel.mapper.CadModelHistoryAnnotationMapper;
|
||||
import com.cadlabel.mapper.CadModelHistoryMapper;
|
||||
import com.cadlabel.mapper.CadModelMapper;
|
||||
import com.cadlabel.mapper.CadStructureHistoryRelMapper;
|
||||
import com.cadlabel.mapper.CadStructureMapper;
|
||||
import com.cadlabel.service.CadAnnotationService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* CAD 标注编排服务。
|
||||
*
|
||||
* <p>负责建模历史标注、结构、功能以及两层关系维护。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CadAnnotationServiceImpl implements CadAnnotationService {
|
||||
|
||||
private final CadModelMapper cadModelMapper;
|
||||
private final CadModelHistoryMapper cadModelHistoryMapper;
|
||||
private final CadModelHistoryAnnotationMapper annotationMapper;
|
||||
private final CadStructureMapper structureMapper;
|
||||
private final CadStructureHistoryRelMapper structureHistoryRelMapper;
|
||||
private final CadFunctionMapper functionMapper;
|
||||
private final CadFunctionStructureRelMapper functionStructureRelMapper;
|
||||
|
||||
/**
|
||||
* 保存建模历史步骤标注。
|
||||
*
|
||||
* <p>先校验模型和历史步骤归属关系,再按 historyId 做插入或更新,保证前端重复保存是幂等的。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public CadModelHistoryAnnotationResponse saveHistoryAnnotation(
|
||||
Long modelId,
|
||||
Long historyId,
|
||||
SaveHistoryAnnotationRequest request
|
||||
) {
|
||||
requireModel(modelId);
|
||||
requireHistory(modelId, historyId);
|
||||
SaveHistoryAnnotationRequest safeRequest = request == null
|
||||
? new SaveHistoryAnnotationRequest(null, null, null)
|
||||
: request;
|
||||
|
||||
CadModelHistoryAnnotation existing = annotationMapper.selectOne(Wrappers.lambdaQuery(CadModelHistoryAnnotation.class)
|
||||
.eq(CadModelHistoryAnnotation::getHistoryId, historyId));
|
||||
if (existing == null) {
|
||||
CadModelHistoryAnnotation annotation = new CadModelHistoryAnnotation();
|
||||
annotation.setModelId(modelId);
|
||||
annotation.setHistoryId(historyId);
|
||||
annotation.setModelingDescription(safeRequest.modelingDescription());
|
||||
annotation.setSupplementDescription(safeRequest.supplementDescription());
|
||||
annotation.setRemark(safeRequest.remark());
|
||||
log.info("Creating CAD history annotation. modelId={}, historyId={}", modelId, historyId);
|
||||
annotationMapper.insert(annotation);
|
||||
return toAnnotationResponse(annotationMapper.selectById(annotation.getId()));
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
log.info("Updating CAD history annotation. modelId={}, historyId={}, annotationId={}",
|
||||
modelId, historyId, existing.getId());
|
||||
annotationMapper.update(null, Wrappers.lambdaUpdate(CadModelHistoryAnnotation.class)
|
||||
.eq(CadModelHistoryAnnotation::getId, existing.getId())
|
||||
.set(CadModelHistoryAnnotation::getModelId, modelId)
|
||||
.set(CadModelHistoryAnnotation::getModelingDescription, safeRequest.modelingDescription())
|
||||
.set(CadModelHistoryAnnotation::getSupplementDescription, safeRequest.supplementDescription())
|
||||
.set(CadModelHistoryAnnotation::getRemark, safeRequest.remark())
|
||||
.set(CadModelHistoryAnnotation::getUpdatedAt, now));
|
||||
return toAnnotationResponse(annotationMapper.selectById(existing.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建结构并绑定建模历史步骤。
|
||||
*
|
||||
* <p>结构基础记录和结构-历史关系在同一事务内写入,任一环节失败都会回滚。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public CadStructureResponse createStructure(Long modelId, CreateCadStructureRequest request) {
|
||||
requireModel(modelId);
|
||||
if (request == null) {
|
||||
throw invalidRequest("请求体不能为空");
|
||||
}
|
||||
validateStructureName(request.structureName());
|
||||
validateMaxLength("structureType", request.structureType(), 64);
|
||||
validateMaxLength("purpose", request.purpose(), 1024);
|
||||
validateMaxLength("solution", request.solution(), 1024);
|
||||
List<Long> historyIds = validateIdList("historyIds", request.historyIds());
|
||||
requireHistories(modelId, historyIds);
|
||||
|
||||
CadStructure structure = new CadStructure();
|
||||
structure.setModelId(modelId);
|
||||
structure.setStructureName(request.structureName());
|
||||
structure.setStructureType(defaultText(request.structureType()));
|
||||
structure.setPurpose(defaultText(request.purpose()));
|
||||
structure.setSolution(defaultText(request.solution()));
|
||||
structure.setReason(request.reason());
|
||||
structure.setRemark(request.remark());
|
||||
structure.setDeleted(0);
|
||||
|
||||
log.info("Creating CAD structure. modelId={}, historyCount={}", modelId, historyIds.size());
|
||||
structureMapper.insert(structure);
|
||||
replaceStructureHistoryRelations(modelId, structure.getId(), historyIds);
|
||||
return getStructure(modelId, structure.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改结构基础信息和历史步骤关系。
|
||||
*
|
||||
* <p>请求未传的普通字段保持原值;传入 historyIds 时会按请求数组全量替换关系。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public CadStructureResponse updateStructure(Long modelId, Long structureId, UpdateCadStructureRequest request) {
|
||||
CadStructure structure = requireStructure(modelId, structureId);
|
||||
if (request == null) {
|
||||
return toStructureResponse(structure);
|
||||
}
|
||||
|
||||
List<String> updatedFields = structureUpdatedFields(request);
|
||||
validateStructureUpdateRequest(request);
|
||||
if (request.historyIds() != null) {
|
||||
List<Long> historyIds = validateIdList("historyIds", request.historyIds());
|
||||
requireHistories(modelId, historyIds);
|
||||
replaceStructureHistoryRelations(modelId, structureId, historyIds);
|
||||
updatedFields.add("historyIds");
|
||||
}
|
||||
|
||||
if (!updatedFields.isEmpty()) {
|
||||
applyStructureUpdate(structure, request);
|
||||
structure.setUpdatedAt(LocalDateTime.now());
|
||||
log.info("Updating CAD structure. modelId={}, structureId={}, fields={}",
|
||||
modelId, structureId, updatedFields);
|
||||
structureMapper.updateById(structure);
|
||||
}
|
||||
return getStructure(modelId, structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模型下的结构列表。
|
||||
*
|
||||
* <p>只查询 deleted=0 的结构,并为每个结构组装已关联的建模历史步骤。</p>
|
||||
*/
|
||||
@Override
|
||||
public List<CadStructureResponse> listStructures(Long modelId) {
|
||||
requireModel(modelId);
|
||||
return structureMapper.selectList(Wrappers.lambdaQuery(CadStructure.class)
|
||||
.eq(CadStructure::getModelId, modelId)
|
||||
.eq(CadStructure::getDeleted, 0)
|
||||
.orderByAsc(CadStructure::getId))
|
||||
.stream()
|
||||
.map(this::toStructureResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询结构详情。
|
||||
*
|
||||
* <p>详情查询会校验结构归属当前模型,并排除已逻辑删除的结构。</p>
|
||||
*/
|
||||
@Override
|
||||
public CadStructureResponse getStructure(Long modelId, Long structureId) {
|
||||
return toStructureResponse(requireStructure(modelId, structureId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除结构,并清理结构关联的建模历史关系和功能引用关系。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteStructure(Long modelId, Long structureId) {
|
||||
requireStructure(modelId, structureId);
|
||||
|
||||
int historyRelationCount = structureHistoryRelMapper.delete(Wrappers.lambdaQuery(CadStructureHistoryRel.class)
|
||||
.eq(CadStructureHistoryRel::getModelId, modelId)
|
||||
.eq(CadStructureHistoryRel::getStructureId, structureId));
|
||||
int functionRelationCount = functionStructureRelMapper.delete(Wrappers.lambdaQuery(CadFunctionStructureRel.class)
|
||||
.eq(CadFunctionStructureRel::getModelId, modelId)
|
||||
.eq(CadFunctionStructureRel::getStructureId, structureId));
|
||||
structureMapper.update(null, Wrappers.lambdaUpdate(CadStructure.class)
|
||||
.eq(CadStructure::getId, structureId)
|
||||
.eq(CadStructure::getModelId, modelId)
|
||||
.eq(CadStructure::getDeleted, 0)
|
||||
.set(CadStructure::getDeleted, 1)
|
||||
.set(CadStructure::getUpdatedAt, LocalDateTime.now()));
|
||||
|
||||
log.info("Deleted CAD structure. modelId={}, structureId={}, historyRelationCount={}, functionRelationCount={}",
|
||||
modelId, structureId, historyRelationCount, functionRelationCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建功能并绑定结构。
|
||||
*
|
||||
* <p>功能基础记录和功能-结构关系在同一事务内写入,关系排序按 structureIds 请求顺序生成。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public CadFunctionResponse createFunction(Long modelId, CreateCadFunctionRequest request) {
|
||||
requireModel(modelId);
|
||||
if (request == null) {
|
||||
throw invalidRequest("请求体不能为空");
|
||||
}
|
||||
validateFunctionName(request.functionName());
|
||||
validateMaxLength("functionType", request.functionType(), 64);
|
||||
List<Long> structureIds = validateIdList("structureIds", request.structureIds());
|
||||
requireStructures(modelId, structureIds);
|
||||
|
||||
CadFunction cadFunction = new CadFunction();
|
||||
cadFunction.setModelId(modelId);
|
||||
cadFunction.setFunctionName(request.functionName());
|
||||
cadFunction.setFunctionType(defaultText(request.functionType()));
|
||||
cadFunction.setGoalDescription(request.goalDescription());
|
||||
cadFunction.setRemark(request.remark());
|
||||
cadFunction.setDeleted(0);
|
||||
|
||||
log.info("Creating CAD function. modelId={}, structureCount={}", modelId, structureIds.size());
|
||||
functionMapper.insert(cadFunction);
|
||||
replaceFunctionStructureRelations(modelId, cadFunction.getId(), structureIds);
|
||||
return getFunction(modelId, cadFunction.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改功能基础信息和结构关系。
|
||||
*
|
||||
* <p>请求未传的普通字段保持原值;传入 structureIds 时会按请求数组全量替换关系。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public CadFunctionResponse updateFunction(Long modelId, Long functionId, UpdateCadFunctionRequest request) {
|
||||
CadFunction cadFunction = requireFunction(modelId, functionId);
|
||||
if (request == null) {
|
||||
return toFunctionResponse(cadFunction);
|
||||
}
|
||||
|
||||
List<String> updatedFields = functionUpdatedFields(request);
|
||||
validateFunctionUpdateRequest(request);
|
||||
if (request.structureIds() != null) {
|
||||
List<Long> structureIds = validateIdList("structureIds", request.structureIds());
|
||||
requireStructures(modelId, structureIds);
|
||||
replaceFunctionStructureRelations(modelId, functionId, structureIds);
|
||||
updatedFields.add("structureIds");
|
||||
}
|
||||
|
||||
if (!updatedFields.isEmpty()) {
|
||||
applyFunctionUpdate(cadFunction, request);
|
||||
cadFunction.setUpdatedAt(LocalDateTime.now());
|
||||
log.info("Updating CAD function. modelId={}, functionId={}, fields={}",
|
||||
modelId, functionId, updatedFields);
|
||||
functionMapper.updateById(cadFunction);
|
||||
}
|
||||
return getFunction(modelId, functionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模型下的功能列表。
|
||||
*
|
||||
* <p>只查询 deleted=0 的功能,并为每个功能组装已关联的结构。</p>
|
||||
*/
|
||||
@Override
|
||||
public List<CadFunctionResponse> listFunctions(Long modelId) {
|
||||
requireModel(modelId);
|
||||
return functionMapper.selectList(Wrappers.lambdaQuery(CadFunction.class)
|
||||
.eq(CadFunction::getModelId, modelId)
|
||||
.eq(CadFunction::getDeleted, 0)
|
||||
.orderByAsc(CadFunction::getId))
|
||||
.stream()
|
||||
.map(this::toFunctionResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询功能详情。
|
||||
*
|
||||
* <p>详情查询会校验功能归属当前模型,并排除已逻辑删除的功能。</p>
|
||||
*/
|
||||
@Override
|
||||
public CadFunctionResponse getFunction(Long modelId, Long functionId) {
|
||||
return toFunctionResponse(requireFunction(modelId, functionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除功能,并清理功能关联的结构关系。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteFunction(Long modelId, Long functionId) {
|
||||
requireFunction(modelId, functionId);
|
||||
|
||||
int structureRelationCount = functionStructureRelMapper.delete(Wrappers.lambdaQuery(CadFunctionStructureRel.class)
|
||||
.eq(CadFunctionStructureRel::getModelId, modelId)
|
||||
.eq(CadFunctionStructureRel::getFunctionId, functionId));
|
||||
functionMapper.update(null, Wrappers.lambdaUpdate(CadFunction.class)
|
||||
.eq(CadFunction::getId, functionId)
|
||||
.eq(CadFunction::getModelId, modelId)
|
||||
.eq(CadFunction::getDeleted, 0)
|
||||
.set(CadFunction::getDeleted, 1)
|
||||
.set(CadFunction::getUpdatedAt, LocalDateTime.now()));
|
||||
|
||||
log.info("Deleted CAD function. modelId={}, functionId={}, structureRelationCount={}",
|
||||
modelId, functionId, structureRelationCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验模型存在并返回模型记录。
|
||||
*/
|
||||
private CadModel requireModel(Long modelId) {
|
||||
validatePositiveId(modelId, "modelId");
|
||||
CadModel model = cadModelMapper.selectById(modelId);
|
||||
if (model == null) {
|
||||
throw new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "CAD 模型不存在");
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验建模历史存在且归属指定模型。
|
||||
*/
|
||||
private CadModelHistory requireHistory(Long modelId, Long historyId) {
|
||||
validatePositiveId(historyId, "historyId");
|
||||
CadModelHistory history = cadModelHistoryMapper.selectOne(Wrappers.lambdaQuery(CadModelHistory.class)
|
||||
.eq(CadModelHistory::getId, historyId)
|
||||
.eq(CadModelHistory::getModelId, modelId));
|
||||
if (history == null) {
|
||||
throw new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "建模历史不存在");
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验结构存在、归属指定模型且未逻辑删除。
|
||||
*/
|
||||
private CadStructure requireStructure(Long modelId, Long structureId) {
|
||||
requireModel(modelId);
|
||||
validatePositiveId(structureId, "structureId");
|
||||
CadStructure structure = structureMapper.selectOne(Wrappers.lambdaQuery(CadStructure.class)
|
||||
.eq(CadStructure::getId, structureId)
|
||||
.eq(CadStructure::getModelId, modelId)
|
||||
.eq(CadStructure::getDeleted, 0));
|
||||
if (structure == null) {
|
||||
throw new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "结构不存在");
|
||||
}
|
||||
return structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验功能存在、归属指定模型且未逻辑删除。
|
||||
*/
|
||||
private CadFunction requireFunction(Long modelId, Long functionId) {
|
||||
requireModel(modelId);
|
||||
validatePositiveId(functionId, "functionId");
|
||||
CadFunction cadFunction = functionMapper.selectOne(Wrappers.lambdaQuery(CadFunction.class)
|
||||
.eq(CadFunction::getId, functionId)
|
||||
.eq(CadFunction::getModelId, modelId)
|
||||
.eq(CadFunction::getDeleted, 0));
|
||||
if (cadFunction == null) {
|
||||
throw new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "功能不存在");
|
||||
}
|
||||
return cadFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量校验建模历史 ID 列表全部归属指定模型。
|
||||
*/
|
||||
private Map<Long, CadModelHistory> requireHistories(Long modelId, List<Long> historyIds) {
|
||||
List<CadModelHistory> histories = cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
|
||||
.eq(CadModelHistory::getModelId, modelId)
|
||||
.in(CadModelHistory::getId, historyIds));
|
||||
if (histories.size() != historyIds.size()) {
|
||||
throw new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "建模历史不存在");
|
||||
}
|
||||
return histories.stream().collect(Collectors.toMap(CadModelHistory::getId, history -> history));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量校验结构 ID 列表全部归属指定模型且未逻辑删除。
|
||||
*/
|
||||
private Map<Long, CadStructure> requireStructures(Long modelId, List<Long> structureIds) {
|
||||
List<CadStructure> structures = structureMapper.selectList(Wrappers.lambdaQuery(CadStructure.class)
|
||||
.eq(CadStructure::getModelId, modelId)
|
||||
.eq(CadStructure::getDeleted, 0)
|
||||
.in(CadStructure::getId, structureIds));
|
||||
if (structures.size() != structureIds.size()) {
|
||||
throw new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "结构不存在");
|
||||
}
|
||||
return structures.stream().collect(Collectors.toMap(CadStructure::getId, structure -> structure));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量替换结构与建模历史步骤关系。
|
||||
*
|
||||
* <p>先删除结构原有关联,再按 historyIds 数组顺序写入 sortOrder。</p>
|
||||
*/
|
||||
private void replaceStructureHistoryRelations(Long modelId, Long structureId, List<Long> historyIds) {
|
||||
structureHistoryRelMapper.delete(Wrappers.lambdaQuery(CadStructureHistoryRel.class)
|
||||
.eq(CadStructureHistoryRel::getStructureId, structureId));
|
||||
for (int index = 0; index < historyIds.size(); index++) {
|
||||
CadStructureHistoryRel relation = new CadStructureHistoryRel();
|
||||
relation.setModelId(modelId);
|
||||
relation.setStructureId(structureId);
|
||||
relation.setHistoryId(historyIds.get(index));
|
||||
relation.setSortOrder(index);
|
||||
structureHistoryRelMapper.insert(relation);
|
||||
}
|
||||
log.info("Replaced CAD structure-history relations. modelId={}, structureId={}, historyCount={}",
|
||||
modelId, structureId, historyIds.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量替换功能与结构关系。
|
||||
*
|
||||
* <p>先删除功能原有关联,再按 structureIds 数组顺序写入 sortOrder。</p>
|
||||
*/
|
||||
private void replaceFunctionStructureRelations(Long modelId, Long functionId, List<Long> structureIds) {
|
||||
functionStructureRelMapper.delete(Wrappers.lambdaQuery(CadFunctionStructureRel.class)
|
||||
.eq(CadFunctionStructureRel::getFunctionId, functionId));
|
||||
for (int index = 0; index < structureIds.size(); index++) {
|
||||
CadFunctionStructureRel relation = new CadFunctionStructureRel();
|
||||
relation.setModelId(modelId);
|
||||
relation.setFunctionId(functionId);
|
||||
relation.setStructureId(structureIds.get(index));
|
||||
relation.setSortOrder(index);
|
||||
functionStructureRelMapper.insert(relation);
|
||||
}
|
||||
log.info("Replaced CAD function-structure relations. modelId={}, functionId={}, structureCount={}",
|
||||
modelId, functionId, structureIds.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将标注实体转换为接口响应。
|
||||
*/
|
||||
private CadModelHistoryAnnotationResponse toAnnotationResponse(CadModelHistoryAnnotation annotation) {
|
||||
return new CadModelHistoryAnnotationResponse(
|
||||
annotation.getId(),
|
||||
annotation.getModelId(),
|
||||
annotation.getHistoryId(),
|
||||
annotation.getModelingDescription(),
|
||||
annotation.getSupplementDescription(),
|
||||
annotation.getRemark(),
|
||||
annotation.getCreatedAt(),
|
||||
annotation.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将结构实体转换为接口响应。
|
||||
*
|
||||
* <p>响应中会按 sortOrder 升序组装结构关联的建模历史步骤。</p>
|
||||
*/
|
||||
private CadStructureResponse toStructureResponse(CadStructure structure) {
|
||||
List<CadStructureHistoryRel> relations = structureHistoryRelMapper.selectList(Wrappers.lambdaQuery(CadStructureHistoryRel.class)
|
||||
.eq(CadStructureHistoryRel::getStructureId, structure.getId())
|
||||
.orderByAsc(CadStructureHistoryRel::getSortOrder)
|
||||
.orderByAsc(CadStructureHistoryRel::getId));
|
||||
Map<Long, CadModelHistory> histories = relations.isEmpty()
|
||||
? Map.of()
|
||||
: cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
|
||||
.eq(CadModelHistory::getModelId, structure.getModelId())
|
||||
.in(CadModelHistory::getId, relations.stream().map(CadStructureHistoryRel::getHistoryId).toList()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(CadModelHistory::getId, history -> history));
|
||||
|
||||
List<CadStructureHistoryItemResponse> historyItems = relations.stream()
|
||||
.map(relation -> {
|
||||
CadModelHistory history = histories.get(relation.getHistoryId());
|
||||
return new CadStructureHistoryItemResponse(
|
||||
relation.getId(),
|
||||
relation.getHistoryId(),
|
||||
history == null ? null : history.getHistoryIndex(),
|
||||
history == null ? null : history.getHistoryName(),
|
||||
history == null ? null : history.getOperationType(),
|
||||
relation.getSortOrder()
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
return new CadStructureResponse(
|
||||
structure.getId(),
|
||||
structure.getModelId(),
|
||||
structure.getStructureName(),
|
||||
structure.getStructureType(),
|
||||
structure.getPurpose(),
|
||||
structure.getSolution(),
|
||||
structure.getReason(),
|
||||
structure.getRemark(),
|
||||
historyItems,
|
||||
structure.getCreatedAt(),
|
||||
structure.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将功能实体转换为接口响应。
|
||||
*
|
||||
* <p>响应中会按 sortOrder 升序组装功能关联的未删除结构。</p>
|
||||
*/
|
||||
private CadFunctionResponse toFunctionResponse(CadFunction cadFunction) {
|
||||
List<CadFunctionStructureRel> relations = functionStructureRelMapper.selectList(Wrappers.lambdaQuery(CadFunctionStructureRel.class)
|
||||
.eq(CadFunctionStructureRel::getFunctionId, cadFunction.getId())
|
||||
.orderByAsc(CadFunctionStructureRel::getSortOrder)
|
||||
.orderByAsc(CadFunctionStructureRel::getId));
|
||||
Map<Long, CadStructure> structures = relations.isEmpty()
|
||||
? Map.of()
|
||||
: structureMapper.selectList(Wrappers.lambdaQuery(CadStructure.class)
|
||||
.eq(CadStructure::getModelId, cadFunction.getModelId())
|
||||
.eq(CadStructure::getDeleted, 0)
|
||||
.in(CadStructure::getId, relations.stream().map(CadFunctionStructureRel::getStructureId).toList()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(CadStructure::getId, structure -> structure));
|
||||
|
||||
List<CadFunctionStructureItemResponse> structureItems = relations.stream()
|
||||
.map(relation -> {
|
||||
CadStructure structure = structures.get(relation.getStructureId());
|
||||
if (structure == null) {
|
||||
return null;
|
||||
}
|
||||
return new CadFunctionStructureItemResponse(
|
||||
relation.getId(),
|
||||
relation.getStructureId(),
|
||||
structure.getStructureName(),
|
||||
structure.getStructureType(),
|
||||
relation.getSortOrder()
|
||||
);
|
||||
})
|
||||
.filter(item -> item != null)
|
||||
.toList();
|
||||
|
||||
return new CadFunctionResponse(
|
||||
cadFunction.getId(),
|
||||
cadFunction.getModelId(),
|
||||
cadFunction.getFunctionName(),
|
||||
cadFunction.getFunctionType(),
|
||||
cadFunction.getGoalDescription(),
|
||||
cadFunction.getRemark(),
|
||||
structureItems,
|
||||
cadFunction.getCreatedAt(),
|
||||
cadFunction.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 ID 字段必须为正数。
|
||||
*/
|
||||
private void validatePositiveId(Long id, String fieldName) {
|
||||
if (id == null || id <= 0) {
|
||||
throw invalidRequest(fieldName + " 必须为正数");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验关联 ID 列表非空、均为正数且不重复。
|
||||
*/
|
||||
private List<Long> validateIdList(String fieldName, List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
throw invalidRequest(fieldName + " 不能为空");
|
||||
}
|
||||
Set<Long> uniqueIds = new LinkedHashSet<>();
|
||||
for (Long id : ids) {
|
||||
if (id == null || id <= 0) {
|
||||
throw invalidRequest(fieldName + " 必须为正数");
|
||||
}
|
||||
if (!uniqueIds.add(id)) {
|
||||
throw invalidRequest(fieldName + " 不能包含重复值");
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(uniqueIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验结构修改请求中的字段长度和必填约束。
|
||||
*/
|
||||
private void validateStructureUpdateRequest(UpdateCadStructureRequest request) {
|
||||
if (request.structureName() != null) {
|
||||
validateStructureName(request.structureName());
|
||||
}
|
||||
validateMaxLength("structureType", request.structureType(), 64);
|
||||
validateMaxLength("purpose", request.purpose(), 1024);
|
||||
validateMaxLength("solution", request.solution(), 1024);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验功能修改请求中的字段长度和必填约束。
|
||||
*/
|
||||
private void validateFunctionUpdateRequest(UpdateCadFunctionRequest request) {
|
||||
if (request.functionName() != null) {
|
||||
validateFunctionName(request.functionName());
|
||||
}
|
||||
validateMaxLength("functionType", request.functionType(), 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验结构名称不能为空且不超过数据库字段长度。
|
||||
*/
|
||||
private void validateStructureName(String structureName) {
|
||||
if (!StringUtils.hasText(structureName)) {
|
||||
throw invalidRequest("structureName 不能为空");
|
||||
}
|
||||
validateMaxLength("structureName", structureName, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验功能名称不能为空且不超过数据库字段长度。
|
||||
*/
|
||||
private void validateFunctionName(String functionName) {
|
||||
if (!StringUtils.hasText(functionName)) {
|
||||
throw invalidRequest("functionName 不能为空");
|
||||
}
|
||||
validateMaxLength("functionName", functionName, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验字符串字段最大长度。
|
||||
*/
|
||||
private void validateMaxLength(String field, String value, int maxLength) {
|
||||
if (value != null && value.length() > maxLength) {
|
||||
throw invalidRequest(field + " 长度不能超过 " + maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可空字符串转换为数据库非空字段默认值。
|
||||
*/
|
||||
private String defaultText(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集结构修改请求中实际传入的字段名,用于日志记录。
|
||||
*/
|
||||
private List<String> structureUpdatedFields(UpdateCadStructureRequest request) {
|
||||
List<String> fields = new ArrayList<>();
|
||||
addField(fields, "structureName", request.structureName());
|
||||
addField(fields, "structureType", request.structureType());
|
||||
addField(fields, "purpose", request.purpose());
|
||||
addField(fields, "solution", request.solution());
|
||||
addField(fields, "reason", request.reason());
|
||||
addField(fields, "remark", request.remark());
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集功能修改请求中实际传入的字段名,用于日志记录。
|
||||
*/
|
||||
private List<String> functionUpdatedFields(UpdateCadFunctionRequest request) {
|
||||
List<String> fields = new ArrayList<>();
|
||||
addField(fields, "functionName", request.functionName());
|
||||
addField(fields, "functionType", request.functionType());
|
||||
addField(fields, "goalDescription", request.goalDescription());
|
||||
addField(fields, "remark", request.remark());
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 非空字段加入更新字段集合。
|
||||
*/
|
||||
private void addField(List<String> fields, String fieldName, Object value) {
|
||||
if (value != null) {
|
||||
fields.add(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将结构修改请求应用到实体对象。
|
||||
*/
|
||||
private void applyStructureUpdate(CadStructure structure, UpdateCadStructureRequest request) {
|
||||
if (request.structureName() != null) {
|
||||
structure.setStructureName(request.structureName());
|
||||
}
|
||||
if (request.structureType() != null) {
|
||||
structure.setStructureType(request.structureType());
|
||||
}
|
||||
if (request.purpose() != null) {
|
||||
structure.setPurpose(request.purpose());
|
||||
}
|
||||
if (request.solution() != null) {
|
||||
structure.setSolution(request.solution());
|
||||
}
|
||||
if (request.reason() != null) {
|
||||
structure.setReason(request.reason());
|
||||
}
|
||||
if (request.remark() != null) {
|
||||
structure.setRemark(request.remark());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将功能修改请求应用到实体对象。
|
||||
*/
|
||||
private void applyFunctionUpdate(CadFunction cadFunction, UpdateCadFunctionRequest request) {
|
||||
if (request.functionName() != null) {
|
||||
cadFunction.setFunctionName(request.functionName());
|
||||
}
|
||||
if (request.functionType() != null) {
|
||||
cadFunction.setFunctionType(request.functionType());
|
||||
}
|
||||
if (request.goalDescription() != null) {
|
||||
cadFunction.setGoalDescription(request.goalDescription());
|
||||
}
|
||||
if (request.remark() != null) {
|
||||
cadFunction.setRemark(request.remark());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造统一的请求参数错误异常。
|
||||
*/
|
||||
private BizException invalidRequest(String message) {
|
||||
return new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), ApiCode.INVALID_REQUEST, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.cadlabel.common.api.ApiCode;
|
||||
import com.cadlabel.common.api.PageResponse;
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.dto.cad.CadModelHistoryResponse;
|
||||
import com.cadlabel.dto.cad.CadModelListQuery;
|
||||
import com.cadlabel.dto.cad.CadModelResponse;
|
||||
import com.cadlabel.dto.cad.UpdateCadModelRequest;
|
||||
import com.cadlabel.entity.CadModel;
|
||||
import com.cadlabel.entity.CadModelHistory;
|
||||
import com.cadlabel.mapper.CadModelHistoryMapper;
|
||||
import com.cadlabel.mapper.CadModelMapper;
|
||||
import com.cadlabel.service.CadModelService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* CAD 模型服务实现。
|
||||
*
|
||||
* <p>负责模型列表、详情、基础信息修改和建模历史查询。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CadModelServiceImpl implements CadModelService {
|
||||
|
||||
private static final int DEFAULT_PAGE = 1;
|
||||
private static final int DEFAULT_SIZE = 10;
|
||||
private static final int MAX_SIZE = 100;
|
||||
private static final Set<String> MODEL_CONVERT_STATUSES = Set.of("pending", "success", "failed", "partial_failed");
|
||||
|
||||
private final CadModelMapper cadModelMapper;
|
||||
private final CadModelHistoryMapper cadModelHistoryMapper;
|
||||
|
||||
/**
|
||||
* 分页查询 CAD 模型列表。
|
||||
*/
|
||||
@Override
|
||||
public PageResponse<CadModelResponse> listModels(CadModelListQuery query) {
|
||||
int page = resolvePage(query == null ? DEFAULT_PAGE : query.page());
|
||||
int size = resolveSize(query == null ? DEFAULT_SIZE : query.size());
|
||||
Long categoryId = query == null ? null : query.categoryId();
|
||||
String convertStatus = normalizeOptionalText(query == null ? null : query.convertStatus());
|
||||
String keyword = normalizeOptionalText(query == null ? null : query.keyword());
|
||||
|
||||
if (categoryId != null && categoryId <= 0) {
|
||||
throw invalidRequest("categoryId 必须为正数");
|
||||
}
|
||||
if (StringUtils.hasText(convertStatus)) {
|
||||
validateModelConvertStatus(convertStatus);
|
||||
}
|
||||
|
||||
Page<CadModel> result = cadModelMapper.selectPage(new Page<>(page, size), new LambdaQueryWrapper<CadModel>()
|
||||
.eq(categoryId != null, CadModel::getCategoryId, categoryId)
|
||||
.eq(StringUtils.hasText(convertStatus), CadModel::getConvertStatus, convertStatus)
|
||||
.and(StringUtils.hasText(keyword), nested -> nested
|
||||
.like(CadModel::getModelName, keyword)
|
||||
.or()
|
||||
.like(CadModel::getSourceSwFilename, keyword))
|
||||
.orderByDesc(CadModel::getCreatedAt)
|
||||
.orderByDesc(CadModel::getId));
|
||||
|
||||
List<CadModelResponse> items = result.getRecords().stream().map(this::toModelResponse).toList();
|
||||
return new PageResponse<>(items, result.getCurrent(), result.getSize(), result.getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CAD 模型详情。
|
||||
*/
|
||||
@Override
|
||||
public CadModelResponse getModel(Long id) {
|
||||
return toModelResponse(requireModel(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 CAD 模型基础信息。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public CadModelResponse updateModel(Long id, UpdateCadModelRequest request) {
|
||||
CadModel model = requireModel(id);
|
||||
List<String> updatedFields = updatedFields(request);
|
||||
if (updatedFields.isEmpty()) {
|
||||
return toModelResponse(model);
|
||||
}
|
||||
|
||||
validateUpdateRequest(request);
|
||||
ensureCategoryFilenameUnique(id, model, request);
|
||||
applyUpdate(model, request);
|
||||
model.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
log.info("Updating CAD model. id={}, fields={}", id, updatedFields);
|
||||
try {
|
||||
cadModelMapper.updateById(model);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw duplicateModelException();
|
||||
}
|
||||
return getModel(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询模型完整建模历史。
|
||||
*/
|
||||
@Override
|
||||
public List<CadModelHistoryResponse> listHistories(Long modelId) {
|
||||
requireModel(modelId);
|
||||
return cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
|
||||
.eq(CadModelHistory::getModelId, modelId)
|
||||
.orderByAsc(CadModelHistory::getHistoryIndex)
|
||||
.orderByAsc(CadModelHistory::getId))
|
||||
.stream()
|
||||
.map(this::toHistoryResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private CadModel requireModel(Long id) {
|
||||
validatePositiveId(id);
|
||||
CadModel model = cadModelMapper.selectById(id);
|
||||
if (model == null) {
|
||||
throw modelNotFound();
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
private void validatePositiveId(Long id) {
|
||||
if (id == null || id <= 0) {
|
||||
throw invalidRequest("id 必须为正数");
|
||||
}
|
||||
}
|
||||
|
||||
private int resolvePage(int page) {
|
||||
if (page < 1) {
|
||||
throw invalidRequest("page 必须大于等于 1");
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
private int resolveSize(int size) {
|
||||
if (size < 1 || size > MAX_SIZE) {
|
||||
throw invalidRequest("size 必须在 1 到 100 之间");
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
private void ensureCategoryFilenameUnique(Long id, CadModel current, UpdateCadModelRequest request) {
|
||||
if (request.categoryId() == null && request.sourceSwFilename() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long categoryId = request.categoryId() == null ? current.getCategoryId() : request.categoryId();
|
||||
String sourceSwFilename = request.sourceSwFilename() == null
|
||||
? current.getSourceSwFilename()
|
||||
: request.sourceSwFilename();
|
||||
Long count = cadModelMapper.selectCount(Wrappers.lambdaQuery(CadModel.class)
|
||||
.eq(CadModel::getCategoryId, categoryId)
|
||||
.eq(CadModel::getSourceSwFilename, sourceSwFilename)
|
||||
.ne(CadModel::getId, id));
|
||||
if (count != null && count > 0) {
|
||||
throw duplicateModelException();
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUpdateRequest(UpdateCadModelRequest request) {
|
||||
if (request.categoryId() != null && request.categoryId() <= 0) {
|
||||
throw invalidRequest("categoryId 必须为正数");
|
||||
}
|
||||
validateMaxLength("modelName", request.modelName(), 255);
|
||||
validateMaxLength("sourceSwFilename", request.sourceSwFilename(), 255);
|
||||
validateMaxLength("sourceSwPath", request.sourceSwPath(), 1024);
|
||||
validateMaxLength("featureTreeJsonPath", request.featureTreeJsonPath(), 1024);
|
||||
validateMaxLength("viewPath", request.viewPath(), 1024);
|
||||
validateMaxLength("applicationScenario", request.applicationScenario(), 255);
|
||||
validateMaxLength("material", request.material(), 255);
|
||||
validateMaxLength("manufacturingMethod", request.manufacturingMethod(), 255);
|
||||
validateMaxLength("unit", request.unit(), 64);
|
||||
validateMaxLength("convertStatus", request.convertStatus(), 32);
|
||||
if (request.convertStatus() != null) {
|
||||
validateModelConvertStatus(request.convertStatus());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMaxLength(String field, String value, int maxLength) {
|
||||
if (value != null && value.length() > maxLength) {
|
||||
throw invalidRequest(field + " 长度不能超过 " + maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyUpdate(CadModel model, UpdateCadModelRequest request) {
|
||||
if (request.categoryId() != null) {
|
||||
model.setCategoryId(request.categoryId());
|
||||
}
|
||||
if (request.modelName() != null) {
|
||||
model.setModelName(request.modelName());
|
||||
}
|
||||
if (request.sourceSwFilename() != null) {
|
||||
model.setSourceSwFilename(request.sourceSwFilename());
|
||||
}
|
||||
if (request.sourceSwPath() != null) {
|
||||
model.setSourceSwPath(request.sourceSwPath());
|
||||
}
|
||||
if (request.featureTreeJsonPath() != null) {
|
||||
model.setFeatureTreeJsonPath(request.featureTreeJsonPath());
|
||||
}
|
||||
if (request.viewPath() != null) {
|
||||
model.setViewPath(request.viewPath());
|
||||
}
|
||||
if (request.applicationScenario() != null) {
|
||||
model.setApplicationScenario(request.applicationScenario());
|
||||
}
|
||||
if (request.material() != null) {
|
||||
model.setMaterial(request.material());
|
||||
}
|
||||
if (request.manufacturingMethod() != null) {
|
||||
model.setManufacturingMethod(request.manufacturingMethod());
|
||||
}
|
||||
if (request.unit() != null) {
|
||||
model.setUnit(request.unit());
|
||||
}
|
||||
if (request.userRequirement() != null) {
|
||||
model.setUserRequirement(request.userRequirement());
|
||||
}
|
||||
if (request.designDescription() != null) {
|
||||
model.setDesignDescription(request.designDescription());
|
||||
}
|
||||
if (request.convertStatus() != null) {
|
||||
model.setConvertStatus(request.convertStatus());
|
||||
}
|
||||
if (request.errorMessage() != null) {
|
||||
model.setErrorMessage(request.errorMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> updatedFields(UpdateCadModelRequest request) {
|
||||
List<String> fields = new ArrayList<>();
|
||||
if (request == null) {
|
||||
return fields;
|
||||
}
|
||||
addField(fields, "categoryId", request.categoryId());
|
||||
addField(fields, "modelName", request.modelName());
|
||||
addField(fields, "sourceSwFilename", request.sourceSwFilename());
|
||||
addField(fields, "sourceSwPath", request.sourceSwPath());
|
||||
addField(fields, "featureTreeJsonPath", request.featureTreeJsonPath());
|
||||
addField(fields, "viewPath", request.viewPath());
|
||||
addField(fields, "applicationScenario", request.applicationScenario());
|
||||
addField(fields, "material", request.material());
|
||||
addField(fields, "manufacturingMethod", request.manufacturingMethod());
|
||||
addField(fields, "unit", request.unit());
|
||||
addField(fields, "userRequirement", request.userRequirement());
|
||||
addField(fields, "designDescription", request.designDescription());
|
||||
addField(fields, "convertStatus", request.convertStatus());
|
||||
addField(fields, "errorMessage", request.errorMessage());
|
||||
return fields;
|
||||
}
|
||||
|
||||
private void addField(List<String> fields, String fieldName, Object value) {
|
||||
if (value != null) {
|
||||
fields.add(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
return StringUtils.hasText(value) ? value.trim() : "";
|
||||
}
|
||||
|
||||
private void validateModelConvertStatus(String status) {
|
||||
if (!MODEL_CONVERT_STATUSES.contains(status)) {
|
||||
throw invalidRequest("convertStatus 取值非法");
|
||||
}
|
||||
}
|
||||
|
||||
private BizException invalidRequest(String message) {
|
||||
return new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), ApiCode.INVALID_REQUEST, message);
|
||||
}
|
||||
|
||||
private BizException modelNotFound() {
|
||||
return new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "CAD 模型不存在");
|
||||
}
|
||||
|
||||
private BizException duplicateModelException() {
|
||||
return new BizException(HttpStatus.CONFLICT.value(), "CAD_MODEL_DUPLICATE", "同分类下源 SolidWorks 文件名已存在");
|
||||
}
|
||||
|
||||
private CadModelResponse toModelResponse(CadModel model) {
|
||||
return new CadModelResponse(
|
||||
model.getId(),
|
||||
model.getCategoryId(),
|
||||
model.getModelName(),
|
||||
model.getSourceSwFilename(),
|
||||
model.getSourceSwPath(),
|
||||
model.getFeatureTreeJsonPath(),
|
||||
model.getViewPath(),
|
||||
model.getApplicationScenario(),
|
||||
model.getMaterial(),
|
||||
model.getManufacturingMethod(),
|
||||
model.getUnit(),
|
||||
model.getUserRequirement(),
|
||||
model.getDesignDescription(),
|
||||
model.getConvertStatus(),
|
||||
model.getErrorMessage(),
|
||||
model.getCreatedAt(),
|
||||
model.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
private CadModelHistoryResponse toHistoryResponse(CadModelHistory history) {
|
||||
return new CadModelHistoryResponse(
|
||||
history.getId(),
|
||||
history.getModelId(),
|
||||
history.getSourceFeatureId(),
|
||||
history.getHistoryIndex(),
|
||||
history.getHistoryName(),
|
||||
history.getOperationType(),
|
||||
history.getStepPath(),
|
||||
history.getGlbPath(),
|
||||
history.getTopologyPath(),
|
||||
history.getConvertStatus(),
|
||||
history.getErrorMessage(),
|
||||
history.getCreatedAt(),
|
||||
history.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.cadlabel.config.CosProperties;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CosObjectUrlResolver {
|
||||
|
||||
private final CosProperties cosProperties;
|
||||
|
||||
public String resolvePublicUrl(String objectKey) {
|
||||
if (!StringUtils.hasText(objectKey)) {
|
||||
return "";
|
||||
}
|
||||
return resolveBaseUrl() + "/" + encodePath(objectKey.trim());
|
||||
}
|
||||
|
||||
private String resolveBaseUrl() {
|
||||
if (StringUtils.hasText(cosProperties.publicBaseUrl())) {
|
||||
return trimTrailingSlash(cosProperties.publicBaseUrl().trim());
|
||||
}
|
||||
return "https://%s.cos.%s.myqcloud.com".formatted(requireText(cosProperties.bucket()), requireText(cosProperties.region()));
|
||||
}
|
||||
|
||||
private String encodePath(String path) {
|
||||
return Arrays.stream(path.split("/", -1))
|
||||
.map(segment -> UriUtils.encodePathSegment(segment, StandardCharsets.UTF_8))
|
||||
.collect(Collectors.joining("/"));
|
||||
}
|
||||
|
||||
private String trimTrailingSlash(String value) {
|
||||
String result = value;
|
||||
while (result.endsWith("/")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String requireText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.config.CosProperties;
|
||||
import com.cadlabel.dto.cos.CosTemporaryCredentialsResponse;
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import com.cadlabel.service.CosService;
|
||||
import com.cadlabel.service.client.CosObjectStorageClient;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CosServiceImpl implements CosService {
|
||||
|
||||
private static final String DEFAULT_UPLOAD_PREFIX = "uploads";
|
||||
private static final long DEFAULT_MAX_UPLOAD_SIZE_BYTES = 100L * 1024L * 1024L;
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE;
|
||||
|
||||
private final CosProperties cosProperties;
|
||||
private final CosObjectStorageClient cosObjectStorageClient;
|
||||
private final CosObjectUrlResolver cosObjectUrlResolver;
|
||||
|
||||
@Override
|
||||
public PrepareCosUploadSessionResponse prepareUploadSession(PrepareCosUploadSessionRequest request) {
|
||||
Duration duration = resolveDuration(request == null ? null : request.duration());
|
||||
String keyPrefix = buildKeyPrefix(request == null ? null : request.directory());
|
||||
CosObjectStorageClient.TemporaryCredentials credentials = cosObjectStorageClient.issueTemporaryCredentials(keyPrefix, duration);
|
||||
return new PrepareCosUploadSessionResponse(
|
||||
requireBucket(),
|
||||
requireRegion(),
|
||||
keyPrefix,
|
||||
new CosTemporaryCredentialsResponse(
|
||||
credentials.tmpSecretId(),
|
||||
credentials.tmpSecretKey(),
|
||||
credentials.sessionToken(),
|
||||
credentials.startTime(),
|
||||
credentials.expiredTime()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CosUploadObjectResponse uploadObject(MultipartFile file, String directory) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), "COS_UPLOAD_FILE_EMPTY", "上传文件不能为空");
|
||||
}
|
||||
long maxUploadSizeBytes = resolveMaxUploadSizeBytes();
|
||||
if (file.getSize() > maxUploadSizeBytes) {
|
||||
throw new BizException(HttpStatus.PAYLOAD_TOO_LARGE.value(), "COS_UPLOAD_FILE_TOO_LARGE", "上传文件超过大小限制");
|
||||
}
|
||||
String objectKey = buildKeyPrefix(directory) + sanitizeFilename(file.getOriginalFilename());
|
||||
try {
|
||||
cosObjectStorageClient.putObject(objectKey, file.getInputStream(), file.getSize(), file.getContentType());
|
||||
} catch (IOException exception) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_UPLOAD_FILE_READ_FAILED",
|
||||
"读取上传文件失败: " + exception.getMessage());
|
||||
}
|
||||
return new CosUploadObjectResponse(
|
||||
requireBucket(),
|
||||
requireRegion(),
|
||||
objectKey,
|
||||
cosObjectUrlResolver.resolvePublicUrl(objectKey),
|
||||
file.getSize(),
|
||||
file.getContentType()
|
||||
);
|
||||
}
|
||||
|
||||
private Duration resolveDuration(Duration requestedDuration) {
|
||||
Duration duration = requestedDuration == null ? cosProperties.uploadSessionTtl() : requestedDuration;
|
||||
if (duration == null) {
|
||||
duration = Duration.ofMinutes(30);
|
||||
}
|
||||
if (duration.isZero() || duration.isNegative()) {
|
||||
throw new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), "COS_UPLOAD_DURATION_INVALID", "COS 上传会话有效期非法");
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
private String buildKeyPrefix(String directory) {
|
||||
return normalizePath(resolveUploadPrefix()) + "/"
|
||||
+ LocalDate.now().format(DATE_FORMATTER) + "/"
|
||||
+ UUID.randomUUID() + "/"
|
||||
+ normalizeOptionalDirectory(directory);
|
||||
}
|
||||
|
||||
private String resolveUploadPrefix() {
|
||||
if (StringUtils.hasText(cosProperties.uploadPrefix())) {
|
||||
return cosProperties.uploadPrefix();
|
||||
}
|
||||
return DEFAULT_UPLOAD_PREFIX;
|
||||
}
|
||||
|
||||
private String normalizeOptionalDirectory(String directory) {
|
||||
String normalized = normalizePath(directory);
|
||||
if (!StringUtils.hasText(normalized)) {
|
||||
return "";
|
||||
}
|
||||
return normalized + "/";
|
||||
}
|
||||
|
||||
private String normalizePath(String path) {
|
||||
String normalized = path == null ? "" : path.trim().replace("\\", "/");
|
||||
String[] segments = normalized.split("/");
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String segment : segments) {
|
||||
String cleaned = sanitizePathSegment(segment);
|
||||
if (!StringUtils.hasText(cleaned)) {
|
||||
continue;
|
||||
}
|
||||
if (!builder.isEmpty()) {
|
||||
builder.append('/');
|
||||
}
|
||||
builder.append(cleaned);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
String normalized = StringUtils.hasText(filename) ? filename.trim().replace("\\", "/") : "file";
|
||||
String basename = normalized.substring(normalized.lastIndexOf('/') + 1);
|
||||
String cleaned = sanitizePathSegment(basename);
|
||||
if (!StringUtils.hasText(cleaned)) {
|
||||
return "file";
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private String sanitizePathSegment(String segment) {
|
||||
if (!StringUtils.hasText(segment)) {
|
||||
return "";
|
||||
}
|
||||
String cleaned = segment.trim()
|
||||
.replace("..", "")
|
||||
.replaceAll("[^A-Za-z0-9._-]", "");
|
||||
while (cleaned.startsWith(".")) {
|
||||
cleaned = cleaned.substring(1);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private long resolveMaxUploadSizeBytes() {
|
||||
long configured = cosProperties.maxUploadSizeBytes();
|
||||
return configured > 0 ? configured : DEFAULT_MAX_UPLOAD_SIZE_BYTES;
|
||||
}
|
||||
|
||||
private String requireBucket() {
|
||||
if (!StringUtils.hasText(cosProperties.bucket())) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: APP_COS_BUCKET");
|
||||
}
|
||||
return cosProperties.bucket().trim();
|
||||
}
|
||||
|
||||
private String requireRegion() {
|
||||
if (!StringUtils.hasText(cosProperties.region())) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: APP_COS_REGION");
|
||||
}
|
||||
return cosProperties.region().trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
datasource:
|
||||
url: ${APP_DB_URL:jdbc:mysql://39.105.93.138:3306/cadlabel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true}
|
||||
username: ${APP_DB_USERNAME:root}
|
||||
password: ${APP_DB_PASSWORD:mysql_QRWm4M}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
pool-name: CadLabelDevHikariCP
|
||||
minimum-idle: 1
|
||||
maximum-pool-size: 10
|
||||
connection-timeout: 30000
|
||||
validation-timeout: 5000
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.cadlabel: DEBUG
|
||||
@@ -0,0 +1,37 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
datasource:
|
||||
url: ${APP_DB_URL}
|
||||
username: ${APP_DB_USERNAME}
|
||||
password: ${APP_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
pool-name: CadLabelProdHikariCP
|
||||
minimum-idle: 5
|
||||
maximum-pool-size: 20
|
||||
connection-timeout: 30000
|
||||
validation-timeout: 5000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
server:
|
||||
tomcat:
|
||||
threads:
|
||||
min-spare: 10
|
||||
max: 200
|
||||
accept-count: 100
|
||||
connection-timeout: 10s
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.cadlabel: INFO
|
||||
org.springframework.web: INFO
|
||||
@@ -0,0 +1,56 @@
|
||||
spring:
|
||||
application:
|
||||
name: cadlabel
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 30s
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: ${APP_MULTIPART_MAX_FILE_SIZE:100MB}
|
||||
max-request-size: ${APP_MULTIPART_MAX_REQUEST_SIZE:100MB}
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
time-zone: Asia/Shanghai
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:8891}
|
||||
shutdown: graceful
|
||||
forward-headers-strategy: framework
|
||||
compression:
|
||||
enabled: true
|
||||
min-response-size: 2KB
|
||||
|
||||
mybatis-plus:
|
||||
global-config:
|
||||
db-config:
|
||||
logic-delete-field: deleted
|
||||
logic-delete-value: 1
|
||||
logic-not-delete-value: 0
|
||||
configuration:
|
||||
map-underscore-to-camel-case: true
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
|
||||
app:
|
||||
cos:
|
||||
secret-id: ${APP_COS_SECRET_ID:}
|
||||
secret-key: ${APP_COS_SECRET_KEY:}
|
||||
bucket: ${APP_COS_BUCKET:}
|
||||
region: ${APP_COS_REGION:ap-beijing}
|
||||
public-base-url: ${APP_COS_PUBLIC_BASE_URL:}
|
||||
upload-session-ttl: ${APP_COS_UPLOAD_SESSION_TTL:PT30M}
|
||||
upload-prefix: ${APP_COS_UPLOAD_PREFIX:uploads}
|
||||
max-upload-size-bytes: ${APP_COS_MAX_UPLOAD_SIZE_BYTES:104857600}
|
||||
|
||||
logging:
|
||||
file:
|
||||
path: ${LOG_PATH:logs}
|
||||
level:
|
||||
root: INFO
|
||||
com.cadlabel: INFO
|
||||
org.springframework.web: INFO
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration scan="true" scanPeriod="60 seconds">
|
||||
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="cadlabel"/>
|
||||
<springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="logs"/>
|
||||
|
||||
<property name="LOG_PATTERN"
|
||||
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{requestId:-}] %logger{36} - %msg%n%ex"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="APP_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/${APP_NAME}.log</file>
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/archive/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>10GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/${APP_NAME}-error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>ERROR</level>
|
||||
</filter>
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/archive/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>50MB</maxFileSize>
|
||||
<maxHistory>60</maxHistory>
|
||||
<totalSizeCap>5GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<springProfile name="dev,test">
|
||||
<logger name="com.cadlabel" level="DEBUG"/>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="APP_FILE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="prod">
|
||||
<logger name="com.cadlabel" level="INFO"/>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="APP_FILE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="default">
|
||||
<logger name="com.cadlabel" level="INFO"/>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
</configuration>
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.cadlabel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class CadLabelApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.cadlabel.common;
|
||||
|
||||
import com.cadlabel.common.api.ApiCode;
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import com.cadlabel.common.api.RequestIdHolder;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ApiResponseTest {
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
RequestIdHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successIncludesOkCodeMessageDataAndRequestId() {
|
||||
RequestIdHolder.set("req-001");
|
||||
|
||||
ApiResponse<String> response = ApiResponse.success("pong");
|
||||
|
||||
assertThat(response.getCode()).isEqualTo(ApiCode.OK);
|
||||
assertThat(response.getMessage()).isEqualTo("success");
|
||||
assertThat(response.getData()).isEqualTo("pong");
|
||||
assertThat(response.getRequestId()).isEqualTo("req-001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureIncludesCodeMessageAndNoData() {
|
||||
RequestIdHolder.set("req-002");
|
||||
|
||||
ApiResponse<Void> response = ApiResponse.failure(ApiCode.INVALID_REQUEST, "bad request");
|
||||
|
||||
assertThat(response.getCode()).isEqualTo(ApiCode.INVALID_REQUEST);
|
||||
assertThat(response.getMessage()).isEqualTo("bad request");
|
||||
assertThat(response.getData()).isNull();
|
||||
assertThat(response.getRequestId()).isEqualTo("req-002");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.exception.GlobalExceptionHandler;
|
||||
import com.cadlabel.common.web.RequestIdFilter;
|
||||
import com.cadlabel.config.JacksonConfig;
|
||||
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadFunctionStructureItemResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadStructureHistoryItemResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadStructureResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
|
||||
import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
|
||||
import com.cadlabel.service.CadAnnotationService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(CadAnnotationController.class)
|
||||
@Import({GlobalExceptionHandler.class, RequestIdFilter.class, JacksonConfig.class})
|
||||
class CadAnnotationControllerTest {
|
||||
|
||||
@MockitoBean
|
||||
private CadAnnotationService cadAnnotationService;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void saveHistoryAnnotationReturnsUnifiedResponse() throws Exception {
|
||||
given(cadAnnotationService.saveHistoryAnnotation(eq(1L), eq(10L), any()))
|
||||
.willReturn(sampleAnnotation());
|
||||
|
||||
mockMvc.perform(post("/api/cad-models/1/histories/10/annotation")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"modelingDescription": "创建底座拉伸",
|
||||
"supplementDescription": "范围覆盖底面",
|
||||
"remark": "人工确认"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.historyId").value(10))
|
||||
.andExpect(jsonPath("$.data.modelingDescription").value("创建底座拉伸"));
|
||||
|
||||
verify(cadAnnotationService).saveHistoryAnnotation(1L, 10L, new SaveHistoryAnnotationRequest(
|
||||
"创建底座拉伸",
|
||||
"范围覆盖底面",
|
||||
"人工确认"
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStructureBindsHistoryIdsAndReturnsRelations() throws Exception {
|
||||
given(cadAnnotationService.createStructure(eq(1L), any())).willReturn(sampleStructure(100L, "加强筋"));
|
||||
|
||||
mockMvc.perform(post("/api/cad-models/1/structures")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"structureName": "加强筋",
|
||||
"structureType": "rib",
|
||||
"purpose": "提高强度",
|
||||
"solution": "多段拉伸",
|
||||
"reason": "受力集中",
|
||||
"remark": "优先复核",
|
||||
"historyIds": [10, 11]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.id").value(100))
|
||||
.andExpect(jsonPath("$.data.structureName").value("加强筋"))
|
||||
.andExpect(jsonPath("$.data.historyItems[0].historyId").value(10))
|
||||
.andExpect(jsonPath("$.data.historyItems[0].sortOrder").value(0));
|
||||
|
||||
verify(cadAnnotationService).createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
"提高强度",
|
||||
"多段拉伸",
|
||||
"受力集中",
|
||||
"优先复核",
|
||||
List.of(10L, 11L)
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateStructureAcceptsPartialJson() throws Exception {
|
||||
given(cadAnnotationService.updateStructure(eq(1L), eq(100L), any()))
|
||||
.willReturn(sampleStructure(100L, "加强筋-修改"));
|
||||
|
||||
mockMvc.perform(post("/api/cad-models/1/structures/100")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"structureName": "加强筋-修改",
|
||||
"historyIds": [11, 10]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.structureName").value("加强筋-修改"));
|
||||
|
||||
verify(cadAnnotationService).updateStructure(1L, 100L, new UpdateCadStructureRequest(
|
||||
"加强筋-修改",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(11L, 10L)
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndGetStructuresReturnUnifiedResponses() throws Exception {
|
||||
given(cadAnnotationService.listStructures(1L)).willReturn(List.of(sampleStructure(100L, "加强筋")));
|
||||
given(cadAnnotationService.getStructure(1L, 100L)).willReturn(sampleStructure(100L, "加强筋"));
|
||||
|
||||
mockMvc.perform(get("/api/cad-models/1/structures"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data[0].id").value(100));
|
||||
|
||||
mockMvc.perform(get("/api/cad-models/1/structures/100"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(100));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteStructureReturnsUnifiedResponse() throws Exception {
|
||||
mockMvc.perform(delete("/api/cad-models/1/structures/100"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"));
|
||||
|
||||
verify(cadAnnotationService).deleteStructure(1L, 100L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAndUpdateFunctionBindStructureIds() throws Exception {
|
||||
given(cadAnnotationService.createFunction(eq(1L), any())).willReturn(sampleFunction(200L, "固定功能"));
|
||||
given(cadAnnotationService.updateFunction(eq(1L), eq(200L), any()))
|
||||
.willReturn(sampleFunction(200L, "固定功能-修改"));
|
||||
|
||||
mockMvc.perform(post("/api/cad-models/1/functions")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"functionName": "固定功能",
|
||||
"functionType": "mounting",
|
||||
"goalDescription": "固定外部零件",
|
||||
"remark": "人工确认",
|
||||
"structureIds": [100]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(200))
|
||||
.andExpect(jsonPath("$.data.structureItems[0].structureId").value(100));
|
||||
|
||||
verify(cadAnnotationService).createFunction(1L, new CreateCadFunctionRequest(
|
||||
"固定功能",
|
||||
"mounting",
|
||||
"固定外部零件",
|
||||
"人工确认",
|
||||
List.of(100L)
|
||||
));
|
||||
|
||||
mockMvc.perform(post("/api/cad-models/1/functions/200")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"functionName": "固定功能-修改",
|
||||
"structureIds": [100]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.functionName").value("固定功能-修改"));
|
||||
|
||||
verify(cadAnnotationService).updateFunction(1L, 200L, new UpdateCadFunctionRequest(
|
||||
"固定功能-修改",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(100L)
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndGetFunctionsReturnUnifiedResponses() throws Exception {
|
||||
given(cadAnnotationService.listFunctions(1L)).willReturn(List.of(sampleFunction(200L, "固定功能")));
|
||||
given(cadAnnotationService.getFunction(1L, 200L)).willReturn(sampleFunction(200L, "固定功能"));
|
||||
|
||||
mockMvc.perform(get("/api/cad-models/1/functions"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data[0].id").value(200));
|
||||
|
||||
mockMvc.perform(get("/api/cad-models/1/functions/200"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(200));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteFunctionReturnsUnifiedResponse() throws Exception {
|
||||
mockMvc.perform(delete("/api/cad-models/1/functions/200"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"));
|
||||
|
||||
verify(cadAnnotationService).deleteFunction(1L, 200L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStructureRejectsEmptyHistoryIdsBeforeService() throws Exception {
|
||||
mockMvc.perform(post("/api/cad-models/1/structures")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"structureName": "加强筋",
|
||||
"historyIds": []
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isUnprocessableEntity())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createFunctionRejectsEmptyStructureIdsBeforeService() throws Exception {
|
||||
mockMvc.perform(post("/api/cad-models/1/functions")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"functionName": "固定功能",
|
||||
"structureIds": []
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isUnprocessableEntity())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
private static CadModelHistoryAnnotationResponse sampleAnnotation() {
|
||||
return new CadModelHistoryAnnotationResponse(
|
||||
1L,
|
||||
1L,
|
||||
10L,
|
||||
"创建底座拉伸",
|
||||
"范围覆盖底面",
|
||||
"人工确认",
|
||||
LocalDateTime.of(2026, 7, 8, 10, 0, 0),
|
||||
LocalDateTime.of(2026, 7, 8, 10, 5, 0)
|
||||
);
|
||||
}
|
||||
|
||||
private static CadStructureResponse sampleStructure(Long id, String structureName) {
|
||||
return new CadStructureResponse(
|
||||
id,
|
||||
1L,
|
||||
structureName,
|
||||
"rib",
|
||||
"提高强度",
|
||||
"多段拉伸",
|
||||
"受力集中",
|
||||
"优先复核",
|
||||
List.of(new CadStructureHistoryItemResponse(1000L, 10L, 1, "凸台-拉伸1", "extrude_add", 0)),
|
||||
LocalDateTime.of(2026, 7, 8, 10, 0, 0),
|
||||
LocalDateTime.of(2026, 7, 8, 10, 5, 0)
|
||||
);
|
||||
}
|
||||
|
||||
private static CadFunctionResponse sampleFunction(Long id, String functionName) {
|
||||
return new CadFunctionResponse(
|
||||
id,
|
||||
1L,
|
||||
functionName,
|
||||
"mounting",
|
||||
"固定外部零件",
|
||||
"人工确认",
|
||||
List.of(new CadFunctionStructureItemResponse(2000L, 100L, "加强筋", "rib", 0)),
|
||||
LocalDateTime.of(2026, 7, 8, 10, 0, 0),
|
||||
LocalDateTime.of(2026, 7, 8, 10, 5, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.api.PageResponse;
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.common.exception.GlobalExceptionHandler;
|
||||
import com.cadlabel.common.web.RequestIdFilter;
|
||||
import com.cadlabel.config.JacksonConfig;
|
||||
import com.cadlabel.dto.cad.CadModelHistoryResponse;
|
||||
import com.cadlabel.dto.cad.CadModelListQuery;
|
||||
import com.cadlabel.dto.cad.CadModelResponse;
|
||||
import com.cadlabel.dto.cad.UpdateCadModelRequest;
|
||||
import com.cadlabel.service.CadModelService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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;
|
||||
|
||||
@WebMvcTest(CadModelController.class)
|
||||
@Import({GlobalExceptionHandler.class, RequestIdFilter.class, JacksonConfig.class})
|
||||
class CadModelControllerTest {
|
||||
|
||||
@MockitoBean
|
||||
private CadModelService cadModelService;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void listModelsReturnsPagedUnifiedResponse() throws Exception {
|
||||
CadModelResponse model = sampleModel(1L, "轴承座");
|
||||
given(cadModelService.listModels(any(CadModelListQuery.class))).willReturn(
|
||||
new PageResponse<>(List.of(model), 2, 5, 11)
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/api/cad-models")
|
||||
.param("page", "2")
|
||||
.param("size", "5")
|
||||
.param("categoryId", "7")
|
||||
.param("convertStatus", "success")
|
||||
.param("keyword", "轴承"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.page").value(2))
|
||||
.andExpect(jsonPath("$.data.size").value(5))
|
||||
.andExpect(jsonPath("$.data.total").value(11))
|
||||
.andExpect(jsonPath("$.data.items[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].modelName").value("轴承座"));
|
||||
|
||||
verify(cadModelService).listModels(new CadModelListQuery(2, 5, 7L, "success", "轴承"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getModelReturnsUnifiedResponse() throws Exception {
|
||||
given(cadModelService.getModel(1L)).willReturn(sampleModel(1L, "连接板"));
|
||||
|
||||
mockMvc.perform(get("/api/cad-models/1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.id").value(1))
|
||||
.andExpect(jsonPath("$.data.modelName").value("连接板"))
|
||||
.andExpect(jsonPath("$.data.createdAt").value("2026-07-08 10:00:00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getModelReturnsNotFoundWhenMissing() throws Exception {
|
||||
given(cadModelService.getModel(404L)).willThrow(
|
||||
new BizException(HttpStatus.NOT_FOUND.value(), "NOT_FOUND", "CAD 模型不存在")
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/api/cad-models/404"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("NOT_FOUND"))
|
||||
.andExpect(jsonPath("$.message").value("CAD 模型不存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateModelAcceptsPartialJsonAndReturnsUpdatedModel() throws Exception {
|
||||
given(cadModelService.updateModel(eq(1L), any())).willReturn(sampleModel(1L, "新名称"));
|
||||
|
||||
mockMvc.perform(post("/api/cad-models/1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"modelName": "新名称",
|
||||
"userRequirement": null
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.modelName").value("新名称"));
|
||||
|
||||
verify(cadModelService).updateModel(1L, new UpdateCadModelRequest(
|
||||
null,
|
||||
"新名称",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listHistoriesReturnsOrderedUnifiedResponse() throws Exception {
|
||||
given(cadModelService.listHistories(1L)).willReturn(List.of(
|
||||
new CadModelHistoryResponse(
|
||||
10L,
|
||||
1L,
|
||||
"feat_001",
|
||||
1,
|
||||
"凸台-拉伸1",
|
||||
"extrude_add",
|
||||
"steps/1.step",
|
||||
"glbs/1.glb",
|
||||
"topologies/1.json",
|
||||
"success",
|
||||
null,
|
||||
LocalDateTime.of(2026, 7, 8, 10, 0, 0),
|
||||
LocalDateTime.of(2026, 7, 8, 10, 5, 0)
|
||||
)
|
||||
));
|
||||
|
||||
mockMvc.perform(get("/api/cad-models/1/histories"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data[0].id").value(10))
|
||||
.andExpect(jsonPath("$.data[0].historyIndex").value(1))
|
||||
.andExpect(jsonPath("$.data[0].historyName").value("凸台-拉伸1"))
|
||||
.andExpect(jsonPath("$.data[0].topologyPath").value("topologies/1.json"));
|
||||
}
|
||||
|
||||
private static CadModelResponse sampleModel(Long id, String modelName) {
|
||||
return new CadModelResponse(
|
||||
id,
|
||||
7L,
|
||||
modelName,
|
||||
"015133.sldprt",
|
||||
"sw/015133.sldprt",
|
||||
"features/015133.json",
|
||||
"views/015133",
|
||||
"工业夹具",
|
||||
"铝合金",
|
||||
"CNC",
|
||||
"mm",
|
||||
"轻量化",
|
||||
"整体设计说明",
|
||||
"success",
|
||||
null,
|
||||
LocalDateTime.of(2026, 7, 8, 10, 0, 0),
|
||||
LocalDateTime.of(2026, 7, 8, 10, 5, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.exception.GlobalExceptionHandler;
|
||||
import com.cadlabel.common.web.RequestIdFilter;
|
||||
import com.cadlabel.dto.cos.CosTemporaryCredentialsResponse;
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import com.cadlabel.service.CosService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
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;
|
||||
|
||||
@WebMvcTest(CosController.class)
|
||||
@Import({GlobalExceptionHandler.class, RequestIdFilter.class})
|
||||
class CosControllerTest {
|
||||
|
||||
@MockitoBean
|
||||
private CosService cosService;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void prepareUploadSessionReturnsUnifiedResponse() throws Exception {
|
||||
given(cosService.prepareUploadSession(any(PrepareCosUploadSessionRequest.class))).willReturn(
|
||||
new PrepareCosUploadSessionResponse(
|
||||
"cadlabel-1250000000",
|
||||
"ap-beijing",
|
||||
"uploads/20260707/id/docs/",
|
||||
new CosTemporaryCredentialsResponse("tmp-id", "tmp-key", "tmp-token", 1000L, 1600L)
|
||||
)
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/cos/upload-sessions")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"directory\":\"docs\",\"duration\":\"PT10M\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.bucket").value("cadlabel-1250000000"))
|
||||
.andExpect(jsonPath("$.data.region").value("ap-beijing"))
|
||||
.andExpect(jsonPath("$.data.keyPrefix").value("uploads/20260707/id/docs/"))
|
||||
.andExpect(jsonPath("$.data.credentials.tmpSecretId").value("tmp-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadObjectReturnsUnifiedResponse() throws Exception {
|
||||
given(cosService.uploadObject(any(), any())).willReturn(
|
||||
new CosUploadObjectResponse(
|
||||
"cadlabel-1250000000",
|
||||
"ap-beijing",
|
||||
"uploads/20260707/id/docs/a.txt",
|
||||
"https://cdn.example.com/uploads/20260707/id/docs/a.txt",
|
||||
12L,
|
||||
"text/plain"
|
||||
)
|
||||
);
|
||||
|
||||
mockMvc.perform(multipart("/api/cos/objects")
|
||||
.file("file", "hello".getBytes())
|
||||
.param("directory", "docs"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.objectKey").value("uploads/20260707/id/docs/a.txt"))
|
||||
.andExpect(jsonPath("$.data.publicUrl").value("https://cdn.example.com/uploads/20260707/id/docs/a.txt"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.cadlabel.controller;
|
||||
|
||||
import com.cadlabel.common.api.ApiCode;
|
||||
import com.cadlabel.common.api.ApiResponse;
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.common.exception.GlobalExceptionHandler;
|
||||
import com.cadlabel.common.web.RequestIdFilter;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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 static org.hamcrest.Matchers.matchesPattern;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
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.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(controllers = GlobalExceptionHandlerTest.ProbeController.class)
|
||||
@Import({GlobalExceptionHandler.class, RequestIdFilter.class, GlobalExceptionHandlerTest.ProbeController.class})
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
@MockitoBean
|
||||
private ProbeService probeService;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void requestIdFilterAddsHeaderAndResponseRequestId() throws Exception {
|
||||
given(probeService.echo(anyString())).willReturn("pong");
|
||||
|
||||
mockMvc.perform(get("/probe/ok").header("X-Request-Id", "rid-123"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("X-Request-Id", "rid-123"))
|
||||
.andExpect(jsonPath("$.code").value(ApiCode.OK))
|
||||
.andExpect(jsonPath("$.message").value("success"))
|
||||
.andExpect(jsonPath("$.data").value("pong"))
|
||||
.andExpect(jsonPath("$.requestId").value("rid-123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedRequestIdIsReturnedWhenHeaderMissing() throws Exception {
|
||||
given(probeService.echo(anyString())).willReturn("pong");
|
||||
|
||||
mockMvc.perform(get("/probe/ok"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("X-Request-Id", matchesPattern("[0-9a-f]{32}")))
|
||||
.andExpect(jsonPath("$.requestId", matchesPattern("[0-9a-f]{32}")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validationExceptionUsesUnifiedInvalidRequestResponse() throws Exception {
|
||||
mockMvc.perform(post("/probe/validate")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnprocessableEntity())
|
||||
.andExpect(jsonPath("$.code").value(ApiCode.INVALID_REQUEST))
|
||||
.andExpect(jsonPath("$.data").doesNotExist())
|
||||
.andExpect(jsonPath("$.requestId").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessExceptionUsesDeclaredStatusAndCode() throws Exception {
|
||||
given(probeService.failBusiness()).willThrow(
|
||||
new BizException(HttpStatus.CONFLICT.value(), "PROBE_CONFLICT", "probe conflict")
|
||||
);
|
||||
|
||||
mockMvc.perform(get("/probe/biz"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("PROBE_CONFLICT"))
|
||||
.andExpect(jsonPath("$.message").value("probe conflict"))
|
||||
.andExpect(jsonPath("$.data").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownExceptionUsesInternalErrorResponse() throws Exception {
|
||||
given(probeService.failUnknown()).willThrow(new IllegalStateException("boom"));
|
||||
|
||||
mockMvc.perform(get("/probe/unknown"))
|
||||
.andExpect(status().isInternalServerError())
|
||||
.andExpect(jsonPath("$.code").value(ApiCode.INTERNAL_ERROR))
|
||||
.andExpect(jsonPath("$.message").value("系统异常"))
|
||||
.andExpect(jsonPath("$.data").doesNotExist());
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/probe")
|
||||
static class ProbeController {
|
||||
|
||||
private final ProbeService probeService;
|
||||
|
||||
ProbeController(ProbeService probeService) {
|
||||
this.probeService = probeService;
|
||||
}
|
||||
|
||||
@GetMapping("/ok")
|
||||
ApiResponse<String> ok() {
|
||||
return ApiResponse.success(probeService.echo("ping"));
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
ApiResponse<String> validate(@Valid @RequestBody ProbeRequest request) {
|
||||
return ApiResponse.success(request.name());
|
||||
}
|
||||
|
||||
@GetMapping("/biz")
|
||||
ApiResponse<String> biz() {
|
||||
return ApiResponse.success(probeService.failBusiness());
|
||||
}
|
||||
|
||||
@GetMapping("/unknown")
|
||||
ApiResponse<String> unknown() {
|
||||
return ApiResponse.success(probeService.failUnknown());
|
||||
}
|
||||
}
|
||||
|
||||
interface ProbeService {
|
||||
|
||||
String echo(String value);
|
||||
|
||||
String failBusiness();
|
||||
|
||||
String failUnknown();
|
||||
}
|
||||
|
||||
record ProbeRequest(@NotBlank String name) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CadStructureResponse;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
|
||||
import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
|
||||
import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
|
||||
import com.cadlabel.service.CadAnnotationService;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:cad-annotation-service;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password="
|
||||
})
|
||||
@ActiveProfiles("test")
|
||||
class CadAnnotationServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private CadAnnotationService cadAnnotationService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_function_structure_rel");
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_function");
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_structure_history_rel");
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_structure");
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history_annotation");
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history");
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model");
|
||||
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_model (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id BIGINT NOT NULL,
|
||||
model_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
source_sw_filename VARCHAR(255) NOT NULL,
|
||||
source_sw_path VARCHAR(1024) NOT NULL,
|
||||
feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
view_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
application_scenario VARCHAR(255) NOT NULL DEFAULT '',
|
||||
material VARCHAR(255) NOT NULL DEFAULT '',
|
||||
manufacturing_method VARCHAR(255) NOT NULL DEFAULT '',
|
||||
unit VARCHAR(64) NOT NULL DEFAULT '',
|
||||
user_requirement TEXT,
|
||||
design_description TEXT,
|
||||
convert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_model_history (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
model_id BIGINT NOT NULL,
|
||||
source_feature_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
history_index INT NOT NULL,
|
||||
history_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
operation_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
step_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
glb_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
topology_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
convert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_model_history_annotation (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
model_id BIGINT NOT NULL,
|
||||
history_id BIGINT NOT NULL,
|
||||
modeling_description TEXT,
|
||||
supplement_description TEXT,
|
||||
remark TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_history_annotation_history (history_id)
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_structure (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
model_id BIGINT NOT NULL,
|
||||
structure_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
structure_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
purpose VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
solution VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
reason TEXT,
|
||||
remark TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted TINYINT NOT NULL DEFAULT 0
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_structure_history_rel (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
model_id BIGINT NOT NULL,
|
||||
structure_id BIGINT NOT NULL,
|
||||
history_id BIGINT NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_structure_history (structure_id, history_id)
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_function (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
model_id BIGINT NOT NULL,
|
||||
function_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
function_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
goal_description TEXT,
|
||||
remark TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted TINYINT NOT NULL DEFAULT 0
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_function_structure_rel (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
model_id BIGINT NOT NULL,
|
||||
function_id BIGINT NOT NULL,
|
||||
structure_id BIGINT NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_function_structure (function_id, structure_id)
|
||||
)
|
||||
""");
|
||||
|
||||
insertModel(1L, "轴承座");
|
||||
insertModel(2L, "连接板");
|
||||
insertHistory(10L, 1L, "feat_001", 1, "凸台-拉伸1");
|
||||
insertHistory(11L, 1L, "feat_002", 2, "倒角1");
|
||||
insertHistory(20L, 2L, "feat_101", 1, "拉伸2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveHistoryAnnotationInsertsThenUpdatesSameHistory() {
|
||||
CadModelHistoryAnnotationResponse created = cadAnnotationService.saveHistoryAnnotation(1L, 10L,
|
||||
new SaveHistoryAnnotationRequest("创建底座", "覆盖底面", "初次标注"));
|
||||
|
||||
CadModelHistoryAnnotationResponse updated = cadAnnotationService.saveHistoryAnnotation(1L, 10L,
|
||||
new SaveHistoryAnnotationRequest("创建底座-修改", "覆盖底面-修改", "复核通过"));
|
||||
|
||||
assertThat(updated.id()).isEqualTo(created.id());
|
||||
assertThat(updated.modelingDescription()).isEqualTo("创建底座-修改");
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM cad_model_history_annotation WHERE history_id = 10",
|
||||
Long.class
|
||||
)).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveHistoryAnnotationRejectsHistoryOutsideModel() {
|
||||
assertThatThrownBy(() -> cadAnnotationService.saveHistoryAnnotation(1L, 20L,
|
||||
new SaveHistoryAnnotationRequest("错误", null, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("建模历史不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStructureWritesRelationsInRequestOrder() {
|
||||
CadStructureResponse response = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
"提高强度",
|
||||
"多段拉伸",
|
||||
"受力集中",
|
||||
"人工确认",
|
||||
List.of(11L, 10L)
|
||||
));
|
||||
|
||||
assertThat(response.id()).isPositive();
|
||||
assertThat(response.historyItems()).extracting(item -> item.historyId()).containsExactly(11L, 10L);
|
||||
assertThat(response.historyItems()).extracting(item -> item.sortOrder()).containsExactly(0, 1);
|
||||
assertThat(jdbcTemplate.queryForList(
|
||||
"SELECT history_id FROM cad_structure_history_rel WHERE structure_id = ? ORDER BY sort_order",
|
||||
Long.class,
|
||||
response.id()
|
||||
)).containsExactly(11L, 10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStructureRejectsDuplicateOrInvalidHistoryIds() {
|
||||
assertThatThrownBy(() -> cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(10L, 10L)
|
||||
)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("historyIds 不能包含重复值");
|
||||
|
||||
assertThatThrownBy(() -> cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(20L)
|
||||
)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("建模历史不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateStructurePartiallyAndReplacesRelations() {
|
||||
CadStructureResponse created = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
"提高强度",
|
||||
"多段拉伸",
|
||||
null,
|
||||
null,
|
||||
List.of(10L, 11L)
|
||||
));
|
||||
|
||||
CadStructureResponse updated = cadAnnotationService.updateStructure(1L, created.id(), new UpdateCadStructureRequest(
|
||||
"加强筋-修改",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"已复核",
|
||||
List.of(11L)
|
||||
));
|
||||
|
||||
assertThat(updated.structureName()).isEqualTo("加强筋-修改");
|
||||
assertThat(updated.structureType()).isEqualTo("rib");
|
||||
assertThat(updated.remark()).isEqualTo("已复核");
|
||||
assertThat(updated.historyItems()).extracting(item -> item.historyId()).containsExactly(11L);
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM cad_structure_history_rel WHERE structure_id = ?",
|
||||
Long.class,
|
||||
created.id()
|
||||
)).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteStructureMarksDeletedAndRemovesRelations() {
|
||||
CadStructureResponse structure = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(10L)
|
||||
));
|
||||
CadFunctionResponse cadFunction = cadAnnotationService.createFunction(1L, new CreateCadFunctionRequest(
|
||||
"固定功能",
|
||||
"mounting",
|
||||
null,
|
||||
null,
|
||||
List.of(structure.id())
|
||||
));
|
||||
|
||||
cadAnnotationService.deleteStructure(1L, structure.id());
|
||||
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT deleted FROM cad_structure WHERE id = ?",
|
||||
Integer.class,
|
||||
structure.id()
|
||||
)).isEqualTo(1);
|
||||
assertThat(cadAnnotationService.listStructures(1L)).isEmpty();
|
||||
assertThat(cadAnnotationService.getFunction(1L, cadFunction.id()).structureItems()).isEmpty();
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM cad_structure_history_rel WHERE structure_id = ?",
|
||||
Long.class,
|
||||
structure.id()
|
||||
)).isEqualTo(0L);
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM cad_function_structure_rel WHERE structure_id = ?",
|
||||
Long.class,
|
||||
structure.id()
|
||||
)).isEqualTo(0L);
|
||||
assertThatThrownBy(() -> cadAnnotationService.getStructure(1L, structure.id()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("结构不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAndUpdateFunctionReplaceStructureRelations() {
|
||||
CadStructureResponse first = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(10L)
|
||||
));
|
||||
CadStructureResponse second = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"安装孔",
|
||||
"hole",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(11L)
|
||||
));
|
||||
|
||||
CadFunctionResponse created = cadAnnotationService.createFunction(1L, new CreateCadFunctionRequest(
|
||||
"固定功能",
|
||||
"mounting",
|
||||
"固定外部零件",
|
||||
"人工确认",
|
||||
List.of(second.id(), first.id())
|
||||
));
|
||||
|
||||
assertThat(created.structureItems()).extracting(item -> item.structureId())
|
||||
.containsExactly(second.id(), first.id());
|
||||
assertThat(created.structureItems()).extracting(item -> item.sortOrder()).containsExactly(0, 1);
|
||||
|
||||
CadFunctionResponse updated = cadAnnotationService.updateFunction(1L, created.id(), new UpdateCadFunctionRequest(
|
||||
"固定功能-修改",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(first.id())
|
||||
));
|
||||
|
||||
assertThat(updated.functionName()).isEqualTo("固定功能-修改");
|
||||
assertThat(updated.functionType()).isEqualTo("mounting");
|
||||
assertThat(updated.structureItems()).extracting(item -> item.structureId()).containsExactly(first.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteFunctionMarksDeletedAndRemovesRelations() {
|
||||
CadStructureResponse structure = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(10L)
|
||||
));
|
||||
CadFunctionResponse cadFunction = cadAnnotationService.createFunction(1L, new CreateCadFunctionRequest(
|
||||
"固定功能",
|
||||
"mounting",
|
||||
null,
|
||||
null,
|
||||
List.of(structure.id())
|
||||
));
|
||||
|
||||
cadAnnotationService.deleteFunction(1L, cadFunction.id());
|
||||
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT deleted FROM cad_function WHERE id = ?",
|
||||
Integer.class,
|
||||
cadFunction.id()
|
||||
)).isEqualTo(1);
|
||||
assertThat(cadAnnotationService.listFunctions(1L)).isEmpty();
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM cad_function_structure_rel WHERE function_id = ?",
|
||||
Long.class,
|
||||
cadFunction.id()
|
||||
)).isEqualTo(0L);
|
||||
assertThatThrownBy(() -> cadAnnotationService.getFunction(1L, cadFunction.id()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("功能不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryStructuresAndFunctionsExcludeDeletedAndKeepRelationOrder() {
|
||||
CadStructureResponse active = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"加强筋",
|
||||
"rib",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(11L, 10L)
|
||||
));
|
||||
CadStructureResponse deleted = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
|
||||
"待删除结构",
|
||||
"temp",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(10L)
|
||||
));
|
||||
jdbcTemplate.update("UPDATE cad_structure SET deleted = 1 WHERE id = ?", deleted.id());
|
||||
|
||||
CadFunctionResponse function = cadAnnotationService.createFunction(1L, new CreateCadFunctionRequest(
|
||||
"固定功能",
|
||||
"mounting",
|
||||
null,
|
||||
null,
|
||||
List.of(active.id())
|
||||
));
|
||||
CadFunctionResponse deletedFunction = cadAnnotationService.createFunction(1L, new CreateCadFunctionRequest(
|
||||
"待删除功能",
|
||||
"temp",
|
||||
null,
|
||||
null,
|
||||
List.of(active.id())
|
||||
));
|
||||
jdbcTemplate.update("UPDATE cad_function SET deleted = 1 WHERE id = ?", deletedFunction.id());
|
||||
|
||||
List<CadStructureResponse> structures = cadAnnotationService.listStructures(1L);
|
||||
List<CadFunctionResponse> functions = cadAnnotationService.listFunctions(1L);
|
||||
|
||||
assertThat(structures).extracting(CadStructureResponse::id).containsExactly(active.id());
|
||||
assertThat(structures.getFirst().historyItems()).extracting(item -> item.historyId()).containsExactly(11L, 10L);
|
||||
assertThat(cadAnnotationService.getStructure(1L, active.id()).historyItems())
|
||||
.extracting(item -> item.sortOrder()).containsExactly(0, 1);
|
||||
|
||||
assertThat(functions).extracting(CadFunctionResponse::id).containsExactly(function.id());
|
||||
assertThat(cadAnnotationService.getFunction(1L, function.id()).structureItems())
|
||||
.extracting(item -> item.structureId()).containsExactly(active.id());
|
||||
}
|
||||
|
||||
private void insertModel(Long id, String modelName) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cad_model (
|
||||
id, category_id, model_name, source_sw_filename, source_sw_path,
|
||||
feature_tree_json_path, view_path, application_scenario, material,
|
||||
manufacturing_method, unit, user_requirement, design_description,
|
||||
convert_status, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
id,
|
||||
7L,
|
||||
modelName,
|
||||
id + ".sldprt",
|
||||
"sw/" + id + ".sldprt",
|
||||
"features/" + id + ".json",
|
||||
"views/" + id,
|
||||
"工业夹具",
|
||||
"铝合金",
|
||||
"CNC",
|
||||
"mm",
|
||||
"需求描述",
|
||||
"设计说明",
|
||||
"success",
|
||||
null,
|
||||
"2026-07-08 10:00:00",
|
||||
"2026-07-08 10:00:00"
|
||||
);
|
||||
}
|
||||
|
||||
private void insertHistory(Long id, Long modelId, String sourceFeatureId, int historyIndex, String historyName) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cad_model_history (
|
||||
id, model_id, source_feature_id, history_index, history_name,
|
||||
operation_type, step_path, glb_path, convert_status, error_message,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
id,
|
||||
modelId,
|
||||
sourceFeatureId,
|
||||
historyIndex,
|
||||
historyName,
|
||||
"extrude_add",
|
||||
"steps/" + historyIndex + ".step",
|
||||
"glbs/" + historyIndex + ".glb",
|
||||
"success",
|
||||
null,
|
||||
"2026-07-08 10:00:00",
|
||||
"2026-07-08 10:00:00"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.cadlabel.common.api.PageResponse;
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.dto.cad.CadModelHistoryResponse;
|
||||
import com.cadlabel.dto.cad.CadModelListQuery;
|
||||
import com.cadlabel.dto.cad.CadModelResponse;
|
||||
import com.cadlabel.dto.cad.UpdateCadModelRequest;
|
||||
import com.cadlabel.service.CadModelService;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:cad-model-service;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password="
|
||||
})
|
||||
@ActiveProfiles("test")
|
||||
class CadModelServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private CadModelService cadModelService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history");
|
||||
jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_model (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id BIGINT NOT NULL,
|
||||
model_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
source_sw_filename VARCHAR(255) NOT NULL,
|
||||
source_sw_path VARCHAR(1024) NOT NULL,
|
||||
feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
view_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
application_scenario VARCHAR(255) NOT NULL DEFAULT '',
|
||||
material VARCHAR(255) NOT NULL DEFAULT '',
|
||||
manufacturing_method VARCHAR(255) NOT NULL DEFAULT '',
|
||||
unit VARCHAR(64) NOT NULL DEFAULT '',
|
||||
user_requirement TEXT,
|
||||
design_description TEXT,
|
||||
convert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_cad_model_category_filename (category_id, source_sw_filename)
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE cad_model_history (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
model_id BIGINT NOT NULL,
|
||||
source_feature_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
history_index INT NOT NULL,
|
||||
history_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
operation_type VARCHAR(64) NOT NULL DEFAULT '',
|
||||
step_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
glb_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
topology_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
convert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_model_feature (model_id, source_feature_id)
|
||||
)
|
||||
""");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listModelsFiltersPaginatesAndSortsByCreatedAtDescending() {
|
||||
insertModel(1L, 7L, "轴承座 A", "a.sldprt", "success", "2026-07-08 10:00:00");
|
||||
insertModel(2L, 7L, "轴承座 B", "b.sldprt", "success", "2026-07-08 11:00:00");
|
||||
insertModel(3L, 7L, "连接板", "c.sldprt", "failed", "2026-07-08 12:00:00");
|
||||
insertModel(4L, 9L, "轴承座 C", "d.sldprt", "success", "2026-07-08 13:00:00");
|
||||
|
||||
PageResponse<CadModelResponse> response = cadModelService.listModels(
|
||||
new CadModelListQuery(1, 1, 7L, "success", "轴承")
|
||||
);
|
||||
|
||||
assertThat(response.page()).isEqualTo(1);
|
||||
assertThat(response.size()).isEqualTo(1);
|
||||
assertThat(response.total()).isEqualTo(2);
|
||||
assertThat(response.items()).extracting(CadModelResponse::id).containsExactly(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listModelsRejectsInvalidQueryValues() {
|
||||
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(0, 10, null, null, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("page 必须大于等于 1");
|
||||
|
||||
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 101, null, null, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("size 必须在 1 到 100 之间");
|
||||
|
||||
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 10, 0L, null, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("categoryId 必须为正数");
|
||||
|
||||
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 10, null, "done", null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("convertStatus 取值非法");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getModelReturnsModelOrNotFound() {
|
||||
insertModel(1L, 7L, "轴承座", "a.sldprt", "success", "2026-07-08 10:00:00");
|
||||
|
||||
CadModelResponse response = cadModelService.getModel(1L);
|
||||
|
||||
assertThat(response.id()).isEqualTo(1L);
|
||||
assertThat(response.modelName()).isEqualTo("轴承座");
|
||||
assertThat(response.sourceSwFilename()).isEqualTo("a.sldprt");
|
||||
|
||||
assertThatThrownBy(() -> cadModelService.getModel(404L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("CAD 模型不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateModelPartiallyAndIgnoresNullFields() {
|
||||
insertModel(1L, 7L, "旧名称", "a.sldprt", "success", "2026-07-08 10:00:00");
|
||||
UpdateCadModelRequest request = new UpdateCadModelRequest(
|
||||
8L,
|
||||
"新名称",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"partial_failed",
|
||||
null
|
||||
);
|
||||
|
||||
CadModelResponse response = cadModelService.updateModel(1L, request);
|
||||
|
||||
assertThat(response.modelName()).isEqualTo("新名称");
|
||||
assertThat(response.categoryId()).isEqualTo(8L);
|
||||
assertThat(response.convertStatus()).isEqualTo("partial_failed");
|
||||
assertThat(response.userRequirement()).isEqualTo("需求描述");
|
||||
assertThat(jdbcTemplate.queryForObject(
|
||||
"SELECT user_requirement FROM cad_model WHERE id = 1",
|
||||
String.class
|
||||
)).isEqualTo("需求描述");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateModelRejectsInvalidAndDuplicateValues() {
|
||||
insertModel(1L, 7L, "模型 A", "a.sldprt", "success", "2026-07-08 10:00:00");
|
||||
insertModel(2L, 7L, "模型 B", "b.sldprt", "success", "2026-07-08 11:00:00");
|
||||
|
||||
assertThatThrownBy(() -> cadModelService.updateModel(1L, new UpdateCadModelRequest(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"done",
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("convertStatus 取值非法");
|
||||
|
||||
assertThatThrownBy(() -> cadModelService.updateModel(2L, new UpdateCadModelRequest(
|
||||
null,
|
||||
null,
|
||||
"a.sldprt",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("同分类下源 SolidWorks 文件名已存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listHistoriesReturnsHistoryIndexOrderAndRequiresExistingModel() {
|
||||
insertModel(1L, 7L, "轴承座", "a.sldprt", "success", "2026-07-08 10:00:00");
|
||||
insertHistory(11L, 1L, "feat_002", 2, "倒角1");
|
||||
insertHistory(10L, 1L, "feat_001", 1, "凸台-拉伸1");
|
||||
|
||||
List<CadModelHistoryResponse> histories = cadModelService.listHistories(1L);
|
||||
|
||||
assertThat(histories).extracting(CadModelHistoryResponse::id).containsExactly(10L, 11L);
|
||||
assertThat(histories).extracting(CadModelHistoryResponse::historyIndex).containsExactly(1, 2);
|
||||
assertThat(histories.getFirst()).hasFieldOrPropertyWithValue("topologyPath", "topologies/1.json");
|
||||
|
||||
assertThatThrownBy(() -> cadModelService.listHistories(404L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("CAD 模型不存在");
|
||||
}
|
||||
|
||||
private void insertModel(Long id, Long categoryId, String modelName, String filename, String status, String createdAt) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cad_model (
|
||||
id, category_id, model_name, source_sw_filename, source_sw_path,
|
||||
feature_tree_json_path, view_path, application_scenario, material,
|
||||
manufacturing_method, unit, user_requirement, design_description,
|
||||
convert_status, error_message, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
id,
|
||||
categoryId,
|
||||
modelName,
|
||||
filename,
|
||||
"sw/" + filename,
|
||||
"features/" + filename + ".json",
|
||||
"views/" + filename,
|
||||
"工业夹具",
|
||||
"铝合金",
|
||||
"CNC",
|
||||
"mm",
|
||||
"需求描述",
|
||||
"设计说明",
|
||||
status,
|
||||
null,
|
||||
createdAt,
|
||||
createdAt
|
||||
);
|
||||
}
|
||||
|
||||
private void insertHistory(Long id, Long modelId, String sourceFeatureId, int historyIndex, String historyName) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO cad_model_history (
|
||||
id, model_id, source_feature_id, history_index, history_name,
|
||||
operation_type, step_path, glb_path, topology_path, convert_status, error_message,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
id,
|
||||
modelId,
|
||||
sourceFeatureId,
|
||||
historyIndex,
|
||||
historyName,
|
||||
"extrude_add",
|
||||
"steps/" + historyIndex + ".step",
|
||||
"glbs/" + historyIndex + ".glb",
|
||||
"topologies/" + historyIndex + ".json",
|
||||
"success",
|
||||
null,
|
||||
"2026-07-08 10:00:00",
|
||||
"2026-07-08 10:00:00"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.cadlabel.common.exception.BizException;
|
||||
import com.cadlabel.config.CosProperties;
|
||||
import com.cadlabel.dto.cos.CosUploadObjectResponse;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
|
||||
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
|
||||
import com.cadlabel.service.client.CosObjectStorageClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class CosServiceTest {
|
||||
|
||||
private final FakeCosObjectStorageClient storageClient = new FakeCosObjectStorageClient();
|
||||
private final CosProperties cosProperties = new CosProperties(
|
||||
"sid",
|
||||
"skey",
|
||||
"cadlabel-1250000000",
|
||||
"ap-beijing",
|
||||
"https://cdn.example.com/assets",
|
||||
Duration.ofMinutes(30),
|
||||
"uploads",
|
||||
100L
|
||||
);
|
||||
private final CosServiceImpl cosService = new CosServiceImpl(cosProperties, storageClient, new CosObjectUrlResolver(cosProperties));
|
||||
|
||||
@Test
|
||||
void prepareUploadSessionBuildsUploadPrefixAndReturnsTemporaryCredentials() {
|
||||
PrepareCosUploadSessionResponse response = cosService.prepareUploadSession(
|
||||
new PrepareCosUploadSessionRequest("manuals", Duration.ofMinutes(10))
|
||||
);
|
||||
|
||||
assertThat(response.bucket()).isEqualTo("cadlabel-1250000000");
|
||||
assertThat(response.region()).isEqualTo("ap-beijing");
|
||||
assertThat(response.keyPrefix()).startsWith("uploads/");
|
||||
assertThat(response.keyPrefix()).contains("/manuals/");
|
||||
assertThat(response.keyPrefix()).endsWith("/");
|
||||
assertThat(response.credentials().tmpSecretId()).isEqualTo("tmp-id");
|
||||
assertThat(storageClient.lastCredentialPrefix.get()).isEqualTo(response.keyPrefix());
|
||||
assertThat(storageClient.lastCredentialDuration.get()).isEqualTo(Duration.ofMinutes(10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadObjectStoresFileWithSanitizedNameAndPublicUrl() {
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"../图纸 A.dwg",
|
||||
"application/octet-stream",
|
||||
"content".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
CosUploadObjectResponse response = cosService.uploadObject(file, "cad/source");
|
||||
|
||||
assertThat(response.bucket()).isEqualTo("cadlabel-1250000000");
|
||||
assertThat(response.region()).isEqualTo("ap-beijing");
|
||||
assertThat(response.objectKey()).startsWith("uploads/");
|
||||
assertThat(response.objectKey()).contains("/cad/source/");
|
||||
assertThat(response.objectKey()).endsWith("/A.dwg");
|
||||
assertThat(response.publicUrl()).isEqualTo("https://cdn.example.com/assets/" + response.objectKey());
|
||||
assertThat(storageClient.lastUploadedKey.get()).isEqualTo(response.objectKey());
|
||||
assertThat(storageClient.lastUploadedContentType.get()).isEqualTo("application/octet-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadObjectRejectsEmptyFiles() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "empty.txt", "text/plain", new byte[0]);
|
||||
|
||||
assertThatThrownBy(() -> cosService.uploadObject(file, "docs"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("上传文件不能为空");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadObjectRejectsOversizedFiles() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "large.txt", "text/plain", new byte[101]);
|
||||
|
||||
assertThatThrownBy(() -> cosService.uploadObject(file, "docs"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("上传文件超过大小限制");
|
||||
}
|
||||
|
||||
private static class FakeCosObjectStorageClient implements CosObjectStorageClient {
|
||||
|
||||
private final AtomicReference<String> lastCredentialPrefix = new AtomicReference<>();
|
||||
private final AtomicReference<Duration> lastCredentialDuration = new AtomicReference<>();
|
||||
private final AtomicReference<String> lastUploadedKey = new AtomicReference<>();
|
||||
private final AtomicReference<String> lastUploadedContentType = new AtomicReference<>();
|
||||
|
||||
@Override
|
||||
public TemporaryCredentials issueTemporaryCredentials(String keyPrefix, Duration duration) {
|
||||
lastCredentialPrefix.set(keyPrefix);
|
||||
lastCredentialDuration.set(duration);
|
||||
return new TemporaryCredentials("tmp-id", "tmp-key", "tmp-token", 1000L, 1600L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putObject(String objectKey, java.io.InputStream inputStream, long contentLength, String contentType) {
|
||||
lastUploadedKey.set(objectKey);
|
||||
lastUploadedContentType.set(contentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker
|
||||
Reference in New Issue
Block a user