6 Commits

Author SHA1 Message Date
admin c0a5fb8e26 cad_model_history新增字段topology_path 2026-07-09 10:40:03 +08:00
admin 43545aa715 结构和功能的删除接口 2026-07-08 18:29:51 +08:00
admin 82255a6feb 跨域配置和接口文档 2026-07-08 17:00:08 +08:00
admin bc18930b7a 标注接口 2026-07-08 16:01:51 +08:00
admin 3f30d574d6 模型列表和建模历史接口 2026-07-08 15:06:28 +08:00
admin 234a166924 init 2026-07-07 15:47:14 +08:00
75 changed files with 5615 additions and 18 deletions
+5 -18
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
## 代码实现原则
- 优先保证业务正确性、可维护性、可读性,再考虑炫技。
- 修改代码时忽略.sql文件,如果遇到需要改表结构,直接发我sql。
## 日志规范
- 关键业务节点必须补充必要日志,方便线上排查。
- 不要打印密码、Token、手机号、身份证、支付密钥等敏感信息。
- 异常日志必须包含足够定位问题的信息。
## 回复规范
- 永远用中文回复。
+42
View File
@@ -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.
+731
View File
@@ -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`,便于后端按请求链路定位日志。
+125
View File
@@ -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();
}
}
+25
View File
@@ -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
+37
View File
@@ -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
+56
View File
@@ -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
+71
View File
@@ -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