8 Commits

Author SHA1 Message Date
likang 5aaabd4270 修改图片预览、json导出 2026-07-15 12:28:51 +08:00
likang 17964d4ee7 新增标注导出和JSON数据集导入 2026-07-14 19:22:28 +08:00
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
82 changed files with 7331 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、手机号、身份证、支付密钥等敏感信息。
- 异常日志必须包含足够定位问题的信息。
## 回复规范
- 永远用中文回复。
+58
View File
@@ -0,0 +1,58 @@
# 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.
## Replace The Annotation Dataset
The import runner accepts only `solidworks.rebuild_extract.v1` JSON files. It clears existing
models, histories, annotations, structures, functions, and their relations before importing the
new dataset. It is disabled by default and should run as a non-web process:
```bash
APP_JSON_DATASET_IMPORT_ENABLED=true \
APP_JSON_DATASET_IMPORT_SOURCE_DIRECTORY='/absolute/path/to/output_json 2' \
APP_JSON_DATASET_IMPORT_CATEGORY_ID=1 \
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.main.web-application-type=none
```
The source files contain feature trees only. The importer creates annotation histories but does
not fabricate STEP, GLB, or topology assets.
+742
View File
@@ -0,0 +1,742 @@
# 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 升序返回。若步骤已有自身标注,响应中的 `annotation`
会携带建模说明、补充描述和备注;未标注的步骤不返回该字段。
#### 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",
"annotation": {
"id": 1001,
"modelId": 1,
"historyId": 101,
"modelingDescription": "通过草图轮廓拉伸形成主体",
"supplementDescription": "该步骤决定主体厚度",
"remark": "已复核",
"createdAt": "2026-07-08 16:21:00",
"updatedAt": "2026-07-08 16:22:00"
},
"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>
+639
View File
@@ -0,0 +1,639 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const API_BASE = process.env.CADLABEL_API_BASE ?? 'http://127.0.0.1:8891/api';
const DEFAULT_SOURCE_ROOT = fs.existsSync(path.resolve(process.cwd(), '../json_data/original'))
? '../json_data/original'
: '../output_json 2';
const SOURCE_ROOT = path.resolve(process.cwd(), process.env.CADLABEL_SOURCE_ROOT ?? DEFAULT_SOURCE_ROOT);
const APPLY = process.argv.includes('--apply');
const FORCE = process.argv.includes('--force');
const onlyModel = valueAfter('--model');
const MARKER = '[AI批量分析:v1]';
const API_TIMEOUT_MS = Number(process.env.CADLABEL_API_TIMEOUT_MS ?? 120_000);
const CLASS_DEFINITIONS = [
{
ids: ['061735', '144358', '240860'],
label: '圆形平板法兰盘',
scene: '通用圆形安装连接面,适用于螺栓固定、端面贴合和中心定位',
primaryFunction: '圆形法兰安装连接'
},
{
ids: ['020543', '080683', '202578', '029447'],
label: '带凸台轴颈法兰',
scene: '轴、套筒或设备端面的定位连接,利用凸台或轴颈建立同轴约束',
primaryFunction: '法兰连接与同轴导向'
},
{
ids: ['080632', '086232', '191643'],
label: '多孔工业圆法兰',
scene: '多螺栓圆周连接、载荷分散和精密安装定位',
primaryFunction: '多螺栓法兰连接'
},
{
ids: ['001563', '002935'],
label: '筒体接头转接法兰',
scene: '筒体、轴套或管状接口之间的同轴转接与支承',
primaryFunction: '筒体接口转接与支承'
},
{
ids: ['001942', '218172'],
label: '薄板环形/异形盲板',
scene: '薄板封盖、垫片式隔离或异形基板固定',
primaryFunction: '封盖隔离与异形安装'
},
{
ids: ['006947', '016033', '108524'],
label: '矩形/方形安装法兰板',
scene: '设备矩形安装面、轴孔定位和底板连接',
primaryFunction: '设备安装与定位'
},
{
ids: ['026613', '177126', '214404', '221476'],
label: '支架底座/耳板法兰',
scene: '支架、底座、耳板或设备主体的承载安装连接',
primaryFunction: '支架承载与底座固定'
},
{
ids: ['052729', '054798', '122225'],
label: '轮毂衬套/厚环',
scene: '轴孔导向、间隔定位、轮毂或厚壁环形连接',
primaryFunction: '轴孔导向与间隔定位'
},
{
ids: ['015133'],
label: '复杂多工位安装座',
scene: '多凸台、多孔位机械组件的承载、定位与多方向装配',
primaryFunction: '多工位承载与装配连接'
},
{
ids: ['083235'],
label: '多层凸台圆形安装座',
scene: '圆形基座、轴颈和多方向接口的同轴支承与紧固',
primaryFunction: '圆形基座支承与多接口连接'
}
];
const GROUP_DEFINITIONS = {
base: {
name: '主体承载基体',
type: '主体结构',
purpose: '建立零件的基本实体、总体包络和主要载荷传递路径。',
reason: '首个加料特征决定零件的基础形态,也是后续孔、槽、凸台和边缘特征的宿主。'
},
boss: {
name: '凸台、轴颈与加强结构',
type: '附加实体结构',
purpose: '形成局部凸台、轴颈、台阶或加强区域,提供定位、支承或局部连接界面。',
reason: '分层加料能够在控制总体重量的同时,把材料布置到定位和受力需要的位置。'
},
cut: {
name: '孔槽、内腔与避让结构',
type: '切除结构',
purpose: '形成通孔、内腔、槽口、轮廓减重或装配避让空间。',
reason: '切除特征用于建立配合空间、功能通道和局部间隙,并避免与配合件干涉。'
},
hole: {
name: '安装与定位孔组',
type: '孔系结构',
purpose: '提供螺栓、销或轴类零件的安装、定位和穿过接口。',
reason: '标准孔特征便于采用通用紧固件和加工刀具,并提高装配互换性。'
},
thread: {
name: '螺纹连接孔组',
type: '螺纹接口结构',
purpose: '提供内螺纹紧固接口,实现可拆卸连接和轴向夹紧。',
reason: '孔向导定义的标准螺纹便于紧固件选型,并使连接规格可追溯。'
},
counterbore: {
name: '沉头紧固孔组',
type: '沉头孔结构',
purpose: '容纳螺钉杆部及头部,使紧固件可靠定位并控制头部突出量。',
reason: '沉头或柱形沉头可提供稳定支承面,减少螺钉头与相邻零件的干涉。'
},
pattern: {
name: '阵列重复结构',
type: '规则阵列结构',
purpose: '按规则间距复制孔、槽或凸台,使重复结构保持一致。',
reason: '参数化阵列保证重复特征的间距、数量和方向一致,便于均匀载荷或多工位装配。'
},
edge: {
name: '边缘过渡与装配处理',
type: '边缘处理结构',
purpose: '通过倒角或圆角改善装配导入、去除锐边并降低局部应力集中。',
reason: '受控边缘过渡可提升装配性、加工安全性和边缘耐久性。'
},
auxiliary: {
name: '辅助几何结构',
type: '辅助结构',
purpose: '承载不能归入主体、孔槽或边缘处理的其他有效建模结果。',
reason: '保留辅助步骤与结构的对应关系,避免建模历史在语义层丢失。'
}
};
function valueAfter(flag) {
const index = process.argv.indexOf(flag);
return index >= 0 ? process.argv[index + 1] : undefined;
}
async function api(relativePath, init) {
const response = await fetch(`${API_BASE}${relativePath}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {})
},
signal: AbortSignal.timeout(API_TIMEOUT_MS)
});
const payload = await response.json();
if (!response.ok || payload.code !== 'OK') {
throw new Error(`${init?.method ?? 'GET'} ${relativePath}: ${payload.message ?? response.status}`);
}
return payload.data;
}
function post(relativePath, body) {
return api(relativePath, { method: 'POST', body: JSON.stringify(body) });
}
function classFor(modelName, geometry) {
const configured = CLASS_DEFINITIONS.find((item) => item.ids.includes(modelName));
if (configured) return configured;
if (modelName.includes('方板+均布圆周孔')) {
return {
label: '圆周孔方形安装板',
scene: '方形安装基面上的圆周均布紧固、定位和载荷分散',
primaryFunction: '方板承载与圆周孔系连接'
};
}
if (modelName.includes('方板+四角孔')) {
return {
label: '多孔方形安装板',
scene: '方形安装基面上的四角紧固、中心定位和附加接口连接',
primaryFunction: '方板承载与多点安装'
};
}
if (modelName.includes('方台')) {
return {
label: geometry.interfaceCount ? '多孔方形安装台' : '方形承载台',
scene: '小型机械组件的平面承载、垫高、定位和紧固安装',
primaryFunction: '方台承载与安装定位'
};
}
if (modelName.includes('圆盘+均布孔')) {
return {
label: '圆周孔圆形安装盘',
scene: '圆形安装面上的周向均布紧固和同轴定位',
primaryFunction: '圆盘承载与圆周孔系连接'
};
}
if (modelName.includes('圆盘')) {
return {
label: '圆形承载盘',
scene: '圆形端面的承载、间隔和同轴安装',
primaryFunction: '圆盘承载与端面定位'
};
}
const sequenceNumber = /^\d+$/.test(modelName) ? Number(modelName) : null;
if (sequenceNumber !== null && sequenceNumber >= 40 && sequenceNumber <= 51) {
return {
label: '多孔圆形法兰盘',
scene: '中心接口与周向孔系组合的法兰连接和同轴定位',
primaryFunction: '圆形法兰连接与孔系定位'
};
}
if (sequenceNumber !== null && sequenceNumber >= 52 && sequenceNumber <= 58) {
return {
label: '双耳异形连接板',
scene: '双圆头或长圆形接口之间的跨距连接、铰接和定位',
primaryFunction: '双接口跨距连接与载荷传递'
};
}
if (sequenceNumber !== null && sequenceNumber >= 59 && sequenceNumber <= 66) {
return {
label: '多孔异形安装板',
scene: '多规格孔位的组合安装、定位和多点紧固',
primaryFunction: '异形板承载与多孔连接'
};
}
if (sequenceNumber !== null && sequenceNumber >= 67) {
return {
label: '复合孔系圆形法兰盘',
scene: '多圈、多规格孔系的法兰连接、精密定位和装配适配',
primaryFunction: '复合孔系法兰连接'
};
}
return geometry.outerCircular ? {
label: '多孔圆形安装盘',
scene: '圆形安装面上的承载、周向紧固和同轴定位',
primaryFunction: '圆盘承载与多孔连接'
} : {
label: '机械安装连接件',
scene: '机械组件的承载、定位和紧固连接',
primaryFunction: '机械承载与装配连接'
};
}
function sourceFor(modelName) {
const filename = path.join(SOURCE_ROOT, `${modelName}.solidworks_rebuild_extract.json`);
if (!fs.existsSync(filename)) {
throw new Error(`缺少源 JSON: ${filename}`);
}
return JSON.parse(fs.readFileSync(filename, 'utf8'));
}
function boundingBox(source) {
const box = source.validation_hints?.part_box_m;
if (!Array.isArray(box) || box.length !== 6) return null;
const dimensions = [box[3] - box[0], box[4] - box[1], box[5] - box[2]].map((value) => value * 1000);
return dimensions.map(formatNumber);
}
function geometryAnalysis(source) {
const box = source.validation_hints?.part_box_m;
const size = Array.isArray(box) && box.length === 6
? [box[3] - box[0], box[4] - box[1], box[5] - box[2]].map((value) => value * 1000)
: [];
const maxSpan = size.length ? Math.max(...size) : 0;
const faces = (source.features ?? []).flatMap((feature) => feature.owned_faces ?? []);
const cylinders = faces
.filter((face) => face.surface?.is_cylinder && Array.isArray(face.surface.cylinder_params))
.map((face) => {
const params = face.surface.cylinder_params;
const center = params.slice(0, 3).map((value) => value * 1000);
const axis = params.slice(3, 6);
const axisIndex = axis.reduce(
(best, value, index) => Math.abs(value) > Math.abs(axis[best] ?? 0) ? index : best,
0
);
const radialDistance = Math.hypot(...center.filter((_, index) => index !== axisIndex));
return { radius: Math.abs(params[6] * 1000), center, radialDistance };
});
const outerCircular = cylinders.some((item) =>
maxSpan > 0 && item.radius >= maxSpan * 0.4 && item.radialDistance <= maxSpan * 0.08
);
const interfaces = cylinders.filter((item) =>
maxSpan > 0 && item.radius <= maxSpan * 0.2
);
const uniqueInterfaces = new Map();
for (const item of interfaces) {
const key = `${formatNumber(item.radius)}:${item.center.map((value) => formatNumber(value)).join(',')}`;
uniqueInterfaces.set(key, item);
}
const diameterGroups = new Map();
for (const item of uniqueInterfaces.values()) {
const diameter = formatNumber(item.radius * 2);
diameterGroups.set(diameter, (diameterGroups.get(diameter) ?? 0) + 1);
}
const interfaceSummary = [...diameterGroups.entries()]
.sort((left, right) => Number(right[0]) - Number(left[0]))
.map(([diameter, count]) => `${count}ר${diameter} mm`)
.join('、');
return {
size,
outerCircular,
interfaceCount: uniqueInterfaces.size,
interfaceSummary
};
}
function formatNumber(value) {
if (!Number.isFinite(value)) return String(value);
return Number(value.toFixed(Math.abs(value) < 10 ? 3 : 2)).toString();
}
function cleanDimensionName(name, modelName) {
return String(name ?? '')
.replace(new RegExp(`@${modelName}\\.Part$`, 'i'), '')
.replace(/@[^@]+$/, '');
}
function dimensionEvidence(feature, step, modelName) {
const dimensions = feature?.dimensions ?? [];
const evidence = [];
for (const item of dimensions) {
const value = Number(item.value);
if (!Number.isFinite(value)) continue;
const name = cleanDimensionName(item.name, modelName);
const angleDimension = /角度|angle/i.test(item.name)
|| (step.type.includes('revolve') && item.name?.startsWith(`D1@${step.name}`))
|| (step.type === 'chamfer' && item.name?.startsWith(`D2@${step.name}`));
if (angleDimension && Math.abs(value) > 20) {
evidence.push(`${name}=${formatNumber(value / 1000 * 180 / Math.PI)}°`);
continue;
}
if (step.type.includes('pattern') && Math.abs(value) >= 1000 && Math.abs(value) <= 20_000) {
evidence.push(`${name}=${formatNumber(value / 1000)}(源阵列计数编码)`);
continue;
}
if (Math.abs(value) > 1000) continue;
evidence.push(`${name}=${formatNumber(value)} mm`);
}
return [...new Set(evidence)].slice(0, 10);
}
function stepGroup(step) {
const type = String(step.type ?? step.operationType ?? '').toLowerCase();
const name = String(step.name ?? step.historyName ?? '').toLowerCase();
if (type === 'chamfer' || type === 'fillet') return 'edge';
if (type.includes('pattern')) return 'pattern';
if (type === 'hole') {
if (/螺纹|thread|tapped/.test(name)) return 'thread';
if (/沉头|凹头|counter|螺钉/.test(name)) return 'counterbore';
return 'hole';
}
if (type.includes('cut')) return 'cut';
if (type.includes('add')) return (step.order ?? step.historyIndex) === 1 ? 'base' : 'boss';
return 'auxiliary';
}
function operationIntent(step) {
const type = step.type.toLowerCase();
if (type === 'extrude_add') return `通过“${step.name}”执行加料拉伸,形成或扩展实体轮廓。`;
if (type === 'revolve_add') return `通过“${step.name}”将截面绕中心轴旋转成形,建立回转主体、轴颈或环形台阶。`;
if (type === 'extrude_cut') return `通过“${step.name}”执行拉伸切除,形成孔、槽、内腔或装配避让区域。`;
if (type === 'revolve_cut') return `通过“${step.name}”执行旋转切除,形成同轴内孔、环槽、锥面或回转台阶。`;
if (type === 'hole') return `通过孔向导特征“${step.name}”建立标准化孔接口,服务于紧固、定位或穿轴装配。`;
if (type === 'linear_pattern') return `通过“${step.name}”按设定方向、间距和数量复制种子特征,形成规则重复结构。`;
if (type === 'chamfer') return `通过“${step.name}”处理选定锐边,形成装配导入面并去除尖锐边缘。`;
if (type === 'fillet') return `通过“${step.name}”对选定边进行圆角过渡,改善应力分布和边缘触感。`;
return `执行建模步骤“${step.name}”,在前序几何基础上形成该步骤对应的有效几何。`;
}
function stepAnnotation(modelName, step, feature, geometry) {
const evidence = dimensionEvidence(feature, step, modelName);
const faceCount = feature?.owned_faces?.length ?? 0;
const group = GROUP_DEFINITIONS[stepGroup(step)];
const compoundProfile = step.type === 'extrude_add' && geometry.interfaceCount > 0
? `该复合草图一次拉伸同时形成主体外轮廓与 ${geometry.interfaceCount} 个圆形内轮廓。`
: '';
return {
modelingDescription: `${operationIntent(step)}${compoundProfile}该步骤在本零件中归入“${group.name}”。`,
supplementDescription: [
`重建顺序第 ${step.order} 步,绑定源特征 ${step.sourceFeatureId}`,
geometry.size.length ? `最终几何包络约为 ${geometry.size.map(formatNumber).join(' × ')} mm。` : null,
compoundProfile ? `圆形接口证据:${geometry.interfaceSummary}` : null,
evidence.length ? `关键参数证据:${evidence.join('')}` : '源特征未提供可直接解释的标量尺寸,语义依据操作类型与几何结果确定。',
faceCount ? `源特征记录 ${faceCount} 个归属面,可用于后续拓扑复核。` : '源特征未记录独占面,需结合前后步骤或最终拓扑复核。'
].filter(Boolean).join(''),
remark: `${MARKER} 参数来自 SolidWorks 提取结果;角度、阵列计数及孔向导深度可能采用插件编码或构造深度,不应在未核对原模型前作为公差或有效加工深度。`
};
}
function groupSteps(histories) {
const result = new Map();
for (const step of histories) {
const key = stepGroup(step);
if (!result.has(key)) result.set(key, []);
result.get(key).push(step);
}
return result;
}
function structurePayload(groupKey, steps, classInfo) {
const definition = GROUP_DEFINITIONS[groupKey];
const names = steps.map((step) => `${step.order}.${step.name}`).join('、');
return {
structureName: definition.name,
structureType: definition.type,
purpose: definition.purpose,
solution: `由建模步骤 ${names} 共同实现;具体尺寸和几何范围见各步骤标注。`,
reason: `${definition.reason} 该判断同时结合“${classInfo.label}”的典型用途。`,
remark: `${MARKER} 结构由实际建模步骤按几何作用归组;若后续获得装配图,应复核其主/辅助结构属性。`,
historyIds: steps.map((step) => step.historyId)
};
}
function geometryInterfaceStructurePayload(step, geometry, classInfo) {
return {
structureName: '复合草图孔系与圆弧接口',
structureType: '孔系/圆弧轮廓结构',
purpose: '在主体成形步骤中同步建立圆形内轮廓,用于紧固、定位、穿轴或装配避让。',
solution: `由建模步骤 ${step.order}.${step.name} 的复合草图一次拉伸形成;检测到 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}`,
reason: `复合草图将主体外轮廓与接口轮廓置于同一成形步骤,可保持相对位置一致;该判断同时结合“${classInfo.label}”的典型用途。`,
remark: `${MARKER} 圆形接口依据源特征归属面的圆柱几何识别;孔的用途、配合等级与加工公差需结合装配对象复核。`,
historyIds: [step.historyId]
};
}
function functionPayloads(classInfo, structures) {
const allIds = structures.map((item) => item.id);
const interfaceStructures = structures.filter((item) => ['cut', 'hole', 'thread', 'counterbore', 'pattern', 'geometry_interface'].includes(item.groupKey));
const edgeStructures = structures.filter((item) => item.groupKey === 'edge');
const payloads = [{
functionName: classInfo.primaryFunction,
functionType: '主功能',
goalDescription: `使该${classInfo.label}在“${classInfo.scene}”场景下完成主体承载、几何定位和连接载荷传递。`,
remark: `${MARKER} 主功能依据数据集分类、零件包络和完整建模历史综合推定,额定载荷与配合关系需结合装配数据确认。`,
structureIds: allIds
}];
if (interfaceStructures.length) {
payloads.push({
functionName: '紧固、定位与装配适配',
functionType: '装配功能',
goalDescription: '利用孔、槽、螺纹、沉头或阵列接口约束配合件位置,容纳标准紧固件并提供必要装配间隙。',
remark: `${MARKER} 具体紧固件规格、孔公差、配合等级和主辅接口关系需结合装配对象复核。`,
structureIds: interfaceStructures.map((item) => item.id)
});
}
if (edgeStructures.length) {
payloads.push({
functionName: '装配导入与边缘耐久',
functionType: '辅助功能',
goalDescription: '通过圆角和倒角减少锐边干涉,改善装配导入,并降低局部磕碰或应力集中风险。',
remark: `${MARKER} 边缘处理功能依据特征类型确定;是否属于关键疲劳区域需结合载荷工况判断。`,
structureIds: edgeStructures.map((item) => item.id)
});
}
return payloads;
}
function modelPayload(model, source, histories, classInfo, geometry) {
const box = boundingBox(source);
const boxText = box ? `零件几何包络约为 ${box.join(' × ')} mm。` : '源数据未提供可解析的零件包络。';
const interfaceText = geometry.interfaceCount > 0
? `复合草图形成 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}`
: '未从源特征归属面中识别出独立的小直径圆形接口。';
const sequence = histories.map((step) => `${step.order}.${step.name}(${step.type})`).join('');
const hasRevolve = histories.some((step) => step.type.includes('revolve'));
const hasHole = histories.some((step) => step.type === 'hole') || geometry.interfaceCount > 0;
const manufacturing = [
hasRevolve ? '回转表面车削或车铣复合加工' : 'CNC 铣削或轮廓加工',
hasHole ? '钻孔/扩孔/攻丝等孔系加工' : null,
histories.some((step) => step.type === 'chamfer' || step.type === 'fillet') ? '边缘倒角/圆角处理' : null
].filter(Boolean).join('');
return {
applicationScenario: classInfo.scene,
material: 'SolidWorks 源模型未指定材料;数据集建议铝合金或钢制测试件,需按载荷与环境确认',
manufacturingMethod: `${manufacturing}(依据建模特征推定,待工艺确认)`,
userRequirement: `设计一件${classInfo.label},用于${classInfo.scene}${boxText}${interfaceText}需保留建模历史中定义的主体、孔槽、连接接口和边缘处理,并保证各接口的相对位置与重建顺序一致。`,
designDescription: `${model.modelName} 被识别为${classInfo.label}${boxText}${interfaceText}有效建模序列为:${sequence}。主体加料特征建立承载包络,独立切除、孔向导或复合草图内轮廓形成装配接口,阵列保证重复结构一致,倒角/圆角改善边缘装配性。材料、载荷、配合公差和制造基准未由源 JSON 完整给出,相关结论均需工程复核。`
};
}
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isCompleteAnnotation(exportData, historyCount) {
const annotatedCount = (exportData.featureIndex ?? []).filter((item) =>
hasText(item.annotation?.modelingIntent)
&& hasText(item.annotation?.description)
&& hasText(item.annotation?.notes)
).length;
return annotatedCount === historyCount
&& (exportData.structures?.length ?? 0) > 0
&& (exportData.functions?.length ?? 0) > 0
&& (exportData.unassignedHistoryIds?.length ?? 0) === 0;
}
function hasUnmanagedAnnotation(exportData) {
const annotations = exportData.featureIndex ?? [];
const existingNotes = annotations.map((item) => item.annotation?.notes).filter(Boolean);
const structureNotes = (exportData.structures ?? []).map((item) => item.notes).filter(Boolean);
const functionNotes = (exportData.functions ?? []).map((item) => item.notes).filter(Boolean);
const notes = [...existingNotes, ...structureNotes, ...functionNotes];
return notes.some((note) => !String(note).startsWith(MARKER));
}
async function ensureStructure(modelId, payload, existingByName) {
const existing = existingByName.get(payload.structureName);
if (!APPLY) return { id: `dry:${payload.structureName}`, groupKey: null };
const saved = existing
? await post(`/cad-models/${modelId}/structures/${existing.id}`, payload)
: await post(`/cad-models/${modelId}/structures`, payload);
existingByName.set(saved.structureName, saved);
return saved;
}
async function ensureFunction(modelId, payload, existingByName) {
const existing = existingByName.get(payload.functionName);
if (!APPLY) return { id: `dry:${payload.functionName}` };
const saved = existing
? await post(`/cad-models/${modelId}/functions/${existing.id}`, payload)
: await post(`/cad-models/${modelId}/functions`, payload);
existingByName.set(saved.functionName, saved);
return saved;
}
async function annotateModel(model) {
const [histories, exportData] = await Promise.all([
api(`/cad-models/${model.id}/histories`),
api(`/cad-models/${model.id}/export/annotation`)
]);
if (!histories.length) return { model: model.modelName, status: 'skipped:no_histories' };
if (!FORCE && isCompleteAnnotation(exportData, histories.length)) {
return { model: model.modelName, status: 'skipped:already_complete' };
}
if (!FORCE && hasUnmanagedAnnotation(exportData)) {
return { model: model.modelName, status: 'skipped:existing_manual_annotation' };
}
const source = sourceFor(model.modelName);
const geometry = geometryAnalysis(source);
const classInfo = classFor(model.modelName, geometry);
const featuresById = new Map((source.features ?? []).map((feature) => [feature.id, feature]));
const normalizedHistories = histories.map((step) => ({
historyId: step.id,
sourceFeatureId: step.sourceFeatureId,
order: step.historyIndex,
name: step.historyName,
type: step.operationType
}));
const groups = groupSteps(normalizedHistories);
const geometryStep = geometry.interfaceCount > 0
? normalizedHistories.find((step) => step.type === 'extrude_add')
: null;
const modelUpdate = modelPayload(model, source, normalizedHistories, classInfo, geometry);
const dryRunStructures = [...groups.keys()].map((groupKey, index) => ({ id: index + 1, groupKey }));
if (geometryStep) dryRunStructures.push({ id: dryRunStructures.length + 1, groupKey: 'geometry_interface' });
if (!APPLY) {
return {
model: model.modelName,
status: 'dry-run',
histories: histories.length,
structures: dryRunStructures.length,
functions: functionPayloads(classInfo, dryRunStructures).length,
circularInterfaces: geometry.interfaceCount,
interfaceSummary: geometry.interfaceSummary || undefined
};
}
await post(`/cad-models/${model.id}`, modelUpdate);
for (const step of normalizedHistories) {
await post(
`/cad-models/${model.id}/histories/${step.historyId}/annotation`,
stepAnnotation(model.modelName, step, featuresById.get(step.sourceFeatureId), geometry)
);
}
const existingStructures = new Map((exportData.structures ?? []).map((item) => [item.name, { id: item.id }]));
const structures = [];
for (const [groupKey, steps] of groups) {
const saved = await ensureStructure(model.id, structurePayload(groupKey, steps, classInfo), existingStructures);
structures.push({ ...saved, groupKey });
}
if (geometryStep) {
const saved = await ensureStructure(
model.id,
geometryInterfaceStructurePayload(geometryStep, geometry, classInfo),
existingStructures
);
structures.push({ ...saved, groupKey: 'geometry_interface' });
}
const existingFunctions = new Map((exportData.functions ?? []).map((item) => [item.name, { id: item.id }]));
for (const payload of functionPayloads(classInfo, structures)) {
await ensureFunction(model.id, payload, existingFunctions);
}
const verified = await api(`/cad-models/${model.id}/export/annotation`);
const annotatedCount = (verified.featureIndex ?? []).filter((item) =>
hasText(item.annotation?.modelingIntent)
&& hasText(item.annotation?.description)
&& hasText(item.annotation?.notes)
).length;
if (!isCompleteAnnotation(verified, histories.length)) {
throw new Error(`${model.modelName} 校验失败: annotated=${annotatedCount}/${histories.length}, structures=${verified.structures?.length ?? 0}, functions=${verified.functions?.length ?? 0}, unassigned=${verified.unassignedHistoryIds}`);
}
return {
model: model.modelName,
status: 'applied',
histories: histories.length,
structures: verified.structures.length,
functions: verified.functions.length,
unassigned: verified.unassignedHistoryIds.length
};
}
async function main() {
const modelsByPage = [];
for (let pageNumber = 1; ; pageNumber += 1) {
const page = await api(`/cad-models?page=${pageNumber}&size=100`);
modelsByPage.push(...page.items);
if (modelsByPage.length >= page.total || page.items.length === 0) break;
}
let models = modelsByPage.sort((left, right) => left.modelName.localeCompare(right.modelName));
if (onlyModel) models = models.filter((model) => model.modelName === onlyModel);
if (!models.length) throw new Error(onlyModel ? `未找到模型 ${onlyModel}` : '数据库中没有模型');
console.log(`${APPLY ? 'APPLY' : 'DRY-RUN'} models=${models.length} sourceRoot=${SOURCE_ROOT}`);
const results = [];
for (const model of models) {
const result = await annotateModel(model);
results.push(result);
console.log(JSON.stringify(result));
}
const applied = results.filter((item) => item.status === 'applied').length;
const dryRun = results.filter((item) => item.status === 'dry-run').length;
const skipped = results.length - applied - dryRun;
console.log(JSON.stringify({ total: results.length, applied, dryRun, skipped }));
}
main().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});
@@ -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,18 @@
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 sessionToken,
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,35 @@
package com.cadlabel.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 本地 SolidWorks 特征树 JSON 数据集导入配置。
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "app.json-dataset-import")
public class JsonDatasetImportProperties {
/** 仅在显式启用时执行全量替换,避免服务启动时误清数据。 */
private boolean enabled;
/** JSON 数据集目录。 */
private String sourceDirectory;
/** Node 后端完整转换产物根目录。 */
private String artifactsDirectory;
/** COS 中的数据集对象前缀。 */
private String cosPrefix = "datasets/solidworks-rebuild-v1";
/** 是否由 Java 导入器上传对象;外部已上传时关闭。 */
private boolean uploadEnabled = true;
/** 是否清空现有标注数据;关闭时仅允许追加不存在的新模型。 */
private boolean replaceExisting = true;
/** 导入模型归属的分类 ID。 */
private long categoryId = 1L;
}
@@ -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,184 @@
package com.cadlabel.controller;
import com.cadlabel.common.api.ApiResponse;
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse;
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;
/**
* 导出当前功能、结构和建模历史步骤标注,供 AI 与原始特征树 JSON 配对使用。
*/
@GetMapping("/export/annotation")
public ApiResponse<CadAnnotationExportResponse> exportCurrentAnnotation(@PathVariable @Positive Long modelId) {
return ApiResponse.success(cadAnnotationService.exportCurrentAnnotation(modelId));
}
/**
* 保存建模历史步骤标注。
*
* <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,22 @@
package com.cadlabel.dto.cad;
import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
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,
CadModelHistoryAnnotationResponse annotation
) {
}
@@ -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,25 @@
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 thumbnailPath,
String applicationScenario,
String material,
String manufacturingMethod,
String unit,
String userRequirement,
String designDescription,
String convertStatus,
String errorMessage,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
@@ -0,0 +1,23 @@
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 = 1024) String thumbnailPath,
@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,114 @@
package com.cadlabel.dto.cad.annotation;
import java.time.LocalDateTime;
import java.util.List;
/**
* 当前 CAD 语义标注导出包。
*
* <p>本文件需与 {@code source.featureTreeJsonPath} 指向的原始特征树 JSON 一起使用。
* 每个 {@code featureIndex} 条目通过 {@code sourceFeature.sourceFeatureId} 绑定原始特征。</p>
*/
public record CadAnnotationExportResponse(
String schemaVersion,
LocalDateTime exportedAt,
String purpose,
Model model,
Source source,
Requirement requirement,
String designSummary,
List<Function> functions,
List<Structure> structures,
List<Feature> featureIndex,
List<Long> unassignedHistoryIds,
List<Relation> relations
) {
public record Model(
Long id,
String name,
Long categoryId,
String applicationScenario,
String material,
String manufacturingMethod,
String unit
) {
}
public record Source(
String sourceSwFilename,
String sourceSwPath,
String featureTreeJsonPath,
String bindingRule
) {
}
public record Requirement(String text) {
}
public record Function(
Long id,
String name,
String type,
String goalDescription,
String notes,
List<Long> structureIds
) {
}
public record Structure(
Long id,
String name,
String type,
String purpose,
String solution,
String reason,
String notes,
List<Long> functionIds,
List<FeatureStep> featureSteps
) {
}
public record FeatureStep(
String relationId,
Integer orderInStructure,
Feature feature
) {
}
public record Feature(
Long historyId,
String name,
String operationType,
Integer order,
SourceFeature sourceFeature,
Annotation annotation
) {
}
public record SourceFeature(
String sourceFeatureId,
String bindingStatus,
String stepPath,
String glbPath,
String topologyPath
) {
}
public record Annotation(
String modelingIntent,
String description,
String notes,
LocalDateTime updatedAt
) {
}
public record Relation(
String id,
String type,
String from,
String to,
Integer order
) {
}
}
@@ -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,67 @@
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 thumbnailPath;
/** 应用场景。 */
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,49 @@
package com.cadlabel.importer;
import com.cadlabel.config.JsonDatasetImportProperties;
import com.cadlabel.service.JsonDatasetImportService;
import java.nio.file.Path;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 显式启用后,将处理完成的 JSON 数据集导入标注后台。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "app.json-dataset-import", name = "enabled", havingValue = "true")
public class JsonDatasetImportRunner implements ApplicationRunner {
private final JsonDatasetImportProperties properties;
private final JsonDatasetImportService jsonDatasetImportService;
@Override
public void run(ApplicationArguments args) {
if (!StringUtils.hasText(properties.getSourceDirectory())) {
throw new IllegalStateException("启用 JSON 数据集导入时必须配置 APP_JSON_DATASET_IMPORT_SOURCE_DIRECTORY");
}
if (!StringUtils.hasText(properties.getArtifactsDirectory())) {
throw new IllegalStateException("启用 JSON 数据集导入时必须配置 APP_JSON_DATASET_IMPORT_ARTIFACTS_DIRECTORY");
}
Path sourceDirectory = Path.of(properties.getSourceDirectory()).toAbsolutePath().normalize();
Path artifactsDirectory = Path.of(properties.getArtifactsDirectory()).toAbsolutePath().normalize();
log.warn("Starting CAD JSON dataset import. sourceDirectory={}, artifactsDirectory={}, replaceExisting={}, categoryId={}",
sourceDirectory, artifactsDirectory, properties.isReplaceExisting(), properties.getCategoryId());
JsonDatasetImportService.ImportSummary summary = jsonDatasetImportService.importDataset(
sourceDirectory,
artifactsDirectory,
properties.getCosPrefix(),
properties.isUploadEnabled(),
properties.isReplaceExisting(),
properties.getCategoryId()
);
log.info("CAD JSON dataset import finished. modelCount={}, historyCount={}",
summary.modelCount(), summary.historyCount());
}
}
@@ -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,81 @@
package com.cadlabel.service;
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse;
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 {
/**
* 导出模型当前的功能、结构、建模历史和步骤语义标注。
*/
CadAnnotationExportResponse exportCurrentAnnotation(Long modelId);
/**
* 保存建模历史步骤标注。
*
* <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,15 @@
package com.cadlabel.service;
import java.nio.file.Path;
/**
* 将 SolidWorks 特征树 JSON 数据集导入标注后台。
*/
public interface JsonDatasetImportService {
ImportSummary importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
boolean uploadEnabled, boolean replaceExisting, long categoryId);
record ImportSummary(int modelCount, int historyCount) {
}
}
@@ -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,151 @@
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.auth.BasicSessionCredentials;
import com.qcloud.cos.auth.COSCredentials;
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";
COSCredentials credentials = StringUtils.hasText(cosProperties.sessionToken())
? new BasicSessionCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey()),
cosProperties.sessionToken().trim())
: new BasicCOSCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey()));
return new COSClient(
credentials,
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,898 @@
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.CadAnnotationExportResponse;
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.HashMap;
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>原始 JSON 不在此接口中复制;每个历史步骤保留 sourceFeatureId
* 训练程序可据此在 featureTreeJsonPath 指向的原始 JSON 中定位对应特征。</p>
*/
@Override
@Transactional(readOnly = true)
public CadAnnotationExportResponse exportCurrentAnnotation(Long modelId) {
CadModel model = requireModel(modelId);
List<CadModelHistory> histories = cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
.eq(CadModelHistory::getModelId, modelId)
.orderByAsc(CadModelHistory::getHistoryIndex)
.orderByAsc(CadModelHistory::getId));
Map<Long, CadModelHistoryAnnotation> annotationsByHistoryId = annotationMapper.selectList(
Wrappers.lambdaQuery(CadModelHistoryAnnotation.class)
.eq(CadModelHistoryAnnotation::getModelId, modelId)
)
.stream()
.collect(Collectors.toMap(CadModelHistoryAnnotation::getHistoryId, annotation -> annotation));
List<CadAnnotationExportResponse.Feature> features = histories.stream()
.map(history -> toExportFeature(history, annotationsByHistoryId.get(history.getId())))
.toList();
Map<Long, CadAnnotationExportResponse.Feature> featureByHistoryId = features.stream()
.collect(Collectors.toMap(CadAnnotationExportResponse.Feature::historyId, feature -> feature));
List<CadStructureResponse> structureResponses = listStructures(modelId);
List<CadFunctionResponse> functionResponses = listFunctions(modelId);
Map<Long, List<Long>> functionIdsByStructureId = new HashMap<>();
List<CadAnnotationExportResponse.Relation> relations = new ArrayList<>();
for (CadFunctionResponse function : functionResponses) {
for (CadFunctionStructureItemResponse item : function.structureItems()) {
functionIdsByStructureId.computeIfAbsent(item.structureId(), ignored -> new ArrayList<>()).add(function.id());
relations.add(new CadAnnotationExportResponse.Relation(
"function_structure:" + item.relationId(),
"realized_by",
"function:" + function.id(),
"structure:" + item.structureId(),
item.sortOrder()
));
}
}
Set<Long> assignedHistoryIds = new LinkedHashSet<>();
List<CadAnnotationExportResponse.Structure> structures = new ArrayList<>();
for (CadStructureResponse structure : structureResponses) {
List<CadAnnotationExportResponse.FeatureStep> featureSteps = new ArrayList<>();
for (CadStructureHistoryItemResponse item : structure.historyItems()) {
CadAnnotationExportResponse.Feature feature = featureByHistoryId.get(item.historyId());
if (feature == null) {
continue;
}
assignedHistoryIds.add(item.historyId());
String relationId = "structure_history:" + item.relationId();
relations.add(new CadAnnotationExportResponse.Relation(
relationId,
"implemented_by",
"structure:" + structure.id(),
"history:" + item.historyId(),
item.sortOrder()
));
featureSteps.add(new CadAnnotationExportResponse.FeatureStep(relationId, item.sortOrder(), feature));
}
structures.add(new CadAnnotationExportResponse.Structure(
structure.id(),
structure.structureName(),
structure.structureType(),
structure.purpose(),
structure.solution(),
structure.reason(),
structure.remark(),
functionIdsByStructureId.getOrDefault(structure.id(), List.of()),
featureSteps
));
}
List<CadAnnotationExportResponse.Function> functions = functionResponses.stream()
.map(function -> new CadAnnotationExportResponse.Function(
function.id(),
function.functionName(),
function.functionType(),
function.goalDescription(),
function.remark(),
function.structureItems().stream()
.map(CadFunctionStructureItemResponse::structureId)
.toList()
))
.toList();
List<Long> unassignedHistoryIds = histories.stream()
.map(CadModelHistory::getId)
.filter(historyId -> !assignedHistoryIds.contains(historyId))
.toList();
log.info("Exporting current CAD annotation. modelId={}, functionCount={}, structureCount={}, historyCount={}",
modelId, functions.size(), structures.size(), features.size());
return new CadAnnotationExportResponse(
"cadlabel.current-annotation.v1",
LocalDateTime.now(),
"Current CAD semantic annotation for AI design-intent learning. Pair this export with source.featureTreeJsonPath.",
new CadAnnotationExportResponse.Model(
model.getId(),
model.getModelName(),
model.getCategoryId(),
model.getApplicationScenario(),
model.getMaterial(),
model.getManufacturingMethod(),
model.getUnit()
),
new CadAnnotationExportResponse.Source(
model.getSourceSwFilename(),
model.getSourceSwPath(),
model.getFeatureTreeJsonPath(),
"Match featureIndex[].sourceFeature.sourceFeatureId to the feature ID in featureTreeJsonPath; historyId is the database record and order is the rebuild sequence."
),
new CadAnnotationExportResponse.Requirement(model.getUserRequirement()),
model.getDesignDescription(),
functions,
structures,
features,
unassignedHistoryIds,
relations
);
}
private CadAnnotationExportResponse.Feature toExportFeature(
CadModelHistory history,
CadModelHistoryAnnotation annotation
) {
return new CadAnnotationExportResponse.Feature(
history.getId(),
history.getHistoryName(),
history.getOperationType(),
history.getHistoryIndex(),
new CadAnnotationExportResponse.SourceFeature(
history.getSourceFeatureId(),
StringUtils.hasText(history.getSourceFeatureId()) ? "bound" : "missing_source_feature_id",
history.getStepPath(),
history.getGlbPath(),
history.getTopologyPath()
),
new CadAnnotationExportResponse.Annotation(
annotation == null ? null : annotation.getModelingDescription(),
annotation == null ? null : annotation.getSupplementDescription(),
annotation == null ? null : annotation.getRemark(),
annotation == null ? null : annotation.getUpdatedAt()
)
);
}
/**
* 保存建模历史步骤标注。
*
* <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,366 @@
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.dto.cad.annotation.CadModelHistoryAnnotationResponse;
import com.cadlabel.entity.CadModel;
import com.cadlabel.entity.CadModelHistory;
import com.cadlabel.entity.CadModelHistoryAnnotation;
import com.cadlabel.mapper.CadModelHistoryAnnotationMapper;
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.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
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;
private final CadModelHistoryAnnotationMapper cadModelHistoryAnnotationMapper;
/**
* 分页查询 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);
List<CadModelHistory> histories = cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
.eq(CadModelHistory::getModelId, modelId)
.orderByAsc(CadModelHistory::getHistoryIndex)
.orderByAsc(CadModelHistory::getId));
Map<Long, CadModelHistoryAnnotation> annotationsByHistoryId = cadModelHistoryAnnotationMapper
.selectList(Wrappers.lambdaQuery(CadModelHistoryAnnotation.class)
.eq(CadModelHistoryAnnotation::getModelId, modelId))
.stream()
.collect(Collectors.toMap(CadModelHistoryAnnotation::getHistoryId, Function.identity()));
return histories.stream()
.map(history -> toHistoryResponse(history, annotationsByHistoryId.get(history.getId())))
.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("thumbnailPath", request.thumbnailPath(), 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.thumbnailPath() != null) {
model.setThumbnailPath(request.thumbnailPath());
}
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, "thumbnailPath", request.thumbnailPath());
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.getThumbnailPath(),
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,
CadModelHistoryAnnotation annotation
) {
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(),
annotation == null ? null : new CadModelHistoryAnnotationResponse(
annotation.getId(),
annotation.getModelId(),
annotation.getHistoryId(),
annotation.getModelingDescription(),
annotation.getSupplementDescription(),
annotation.getRemark(),
annotation.getCreatedAt(),
annotation.getUpdatedAt()
)
);
}
}
@@ -0,0 +1,49 @@
package com.cadlabel.service.impl;
import com.cadlabel.config.CosProperties;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriUtils;
@Component
@RequiredArgsConstructor
public class CosObjectUrlResolver {
private final CosProperties cosProperties;
public String resolvePublicUrl(String objectKey) {
if (!StringUtils.hasText(objectKey)) {
return "";
}
return resolveBaseUrl() + "/" + encodePath(objectKey.trim());
}
private String resolveBaseUrl() {
if (StringUtils.hasText(cosProperties.publicBaseUrl())) {
return trimTrailingSlash(cosProperties.publicBaseUrl().trim());
}
return "https://%s.cos.%s.myqcloud.com".formatted(requireText(cosProperties.bucket()), requireText(cosProperties.region()));
}
private String encodePath(String path) {
return Arrays.stream(path.split("/", -1))
.map(segment -> UriUtils.encodePathSegment(segment, StandardCharsets.UTF_8))
.collect(Collectors.joining("/"));
}
private String trimTrailingSlash(String value) {
String result = value;
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
private String requireText(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,170 @@
package com.cadlabel.service.impl;
import com.cadlabel.common.exception.BizException;
import com.cadlabel.config.CosProperties;
import com.cadlabel.dto.cos.CosTemporaryCredentialsResponse;
import com.cadlabel.dto.cos.CosUploadObjectResponse;
import com.cadlabel.dto.cos.PrepareCosUploadSessionRequest;
import com.cadlabel.dto.cos.PrepareCosUploadSessionResponse;
import com.cadlabel.service.CosService;
import com.cadlabel.service.client.CosObjectStorageClient;
import java.io.IOException;
import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@Service
@RequiredArgsConstructor
public class CosServiceImpl implements CosService {
private static final String DEFAULT_UPLOAD_PREFIX = "uploads";
private static final long DEFAULT_MAX_UPLOAD_SIZE_BYTES = 100L * 1024L * 1024L;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE;
private final CosProperties cosProperties;
private final CosObjectStorageClient cosObjectStorageClient;
private final CosObjectUrlResolver cosObjectUrlResolver;
@Override
public PrepareCosUploadSessionResponse prepareUploadSession(PrepareCosUploadSessionRequest request) {
Duration duration = resolveDuration(request == null ? null : request.duration());
String keyPrefix = buildKeyPrefix(request == null ? null : request.directory());
CosObjectStorageClient.TemporaryCredentials credentials = cosObjectStorageClient.issueTemporaryCredentials(keyPrefix, duration);
return new PrepareCosUploadSessionResponse(
requireBucket(),
requireRegion(),
keyPrefix,
new CosTemporaryCredentialsResponse(
credentials.tmpSecretId(),
credentials.tmpSecretKey(),
credentials.sessionToken(),
credentials.startTime(),
credentials.expiredTime()
)
);
}
@Override
public CosUploadObjectResponse uploadObject(MultipartFile file, String directory) {
if (file == null || file.isEmpty()) {
throw new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), "COS_UPLOAD_FILE_EMPTY", "上传文件不能为空");
}
long maxUploadSizeBytes = resolveMaxUploadSizeBytes();
if (file.getSize() > maxUploadSizeBytes) {
throw new BizException(HttpStatus.PAYLOAD_TOO_LARGE.value(), "COS_UPLOAD_FILE_TOO_LARGE", "上传文件超过大小限制");
}
String objectKey = buildKeyPrefix(directory) + sanitizeFilename(file.getOriginalFilename());
try {
cosObjectStorageClient.putObject(objectKey, file.getInputStream(), file.getSize(), file.getContentType());
} catch (IOException exception) {
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_UPLOAD_FILE_READ_FAILED",
"读取上传文件失败: " + exception.getMessage());
}
return new CosUploadObjectResponse(
requireBucket(),
requireRegion(),
objectKey,
cosObjectUrlResolver.resolvePublicUrl(objectKey),
file.getSize(),
file.getContentType()
);
}
private Duration resolveDuration(Duration requestedDuration) {
Duration duration = requestedDuration == null ? cosProperties.uploadSessionTtl() : requestedDuration;
if (duration == null) {
duration = Duration.ofMinutes(30);
}
if (duration.isZero() || duration.isNegative()) {
throw new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), "COS_UPLOAD_DURATION_INVALID", "COS 上传会话有效期非法");
}
return duration;
}
private String buildKeyPrefix(String directory) {
return normalizePath(resolveUploadPrefix()) + "/"
+ LocalDate.now().format(DATE_FORMATTER) + "/"
+ UUID.randomUUID() + "/"
+ normalizeOptionalDirectory(directory);
}
private String resolveUploadPrefix() {
if (StringUtils.hasText(cosProperties.uploadPrefix())) {
return cosProperties.uploadPrefix();
}
return DEFAULT_UPLOAD_PREFIX;
}
private String normalizeOptionalDirectory(String directory) {
String normalized = normalizePath(directory);
if (!StringUtils.hasText(normalized)) {
return "";
}
return normalized + "/";
}
private String normalizePath(String path) {
String normalized = path == null ? "" : path.trim().replace("\\", "/");
String[] segments = normalized.split("/");
StringBuilder builder = new StringBuilder();
for (String segment : segments) {
String cleaned = sanitizePathSegment(segment);
if (!StringUtils.hasText(cleaned)) {
continue;
}
if (!builder.isEmpty()) {
builder.append('/');
}
builder.append(cleaned);
}
return builder.toString();
}
private String sanitizeFilename(String filename) {
String normalized = StringUtils.hasText(filename) ? filename.trim().replace("\\", "/") : "file";
String basename = normalized.substring(normalized.lastIndexOf('/') + 1);
String cleaned = sanitizePathSegment(basename);
if (!StringUtils.hasText(cleaned)) {
return "file";
}
return cleaned;
}
private String sanitizePathSegment(String segment) {
if (!StringUtils.hasText(segment)) {
return "";
}
String cleaned = segment.trim()
.replace("..", "")
.replaceAll("[^A-Za-z0-9._-]", "");
while (cleaned.startsWith(".")) {
cleaned = cleaned.substring(1);
}
return cleaned;
}
private long resolveMaxUploadSizeBytes() {
long configured = cosProperties.maxUploadSizeBytes();
return configured > 0 ? configured : DEFAULT_MAX_UPLOAD_SIZE_BYTES;
}
private String requireBucket() {
if (!StringUtils.hasText(cosProperties.bucket())) {
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: APP_COS_BUCKET");
}
return cosProperties.bucket().trim();
}
private String requireRegion() {
if (!StringUtils.hasText(cosProperties.region())) {
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: APP_COS_REGION");
}
return cosProperties.region().trim();
}
}
@@ -0,0 +1,311 @@
package com.cadlabel.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.cadlabel.entity.CadModel;
import com.cadlabel.entity.CadModelHistory;
import com.cadlabel.mapper.CadModelHistoryMapper;
import com.cadlabel.mapper.CadModelMapper;
import com.cadlabel.service.JsonDatasetImportService;
import com.cadlabel.service.client.CosObjectStorageClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/** 将 Node 后端完成重建的 SolidWorks 数据集上传对象存储并导入远程标注数据。 */
@Slf4j
@Service
@RequiredArgsConstructor
public class SolidWorksJsonDatasetImportService implements JsonDatasetImportService {
private static final String SUPPORTED_SCHEMA = "solidworks.rebuild_extract.v1";
private static final String PREVIEW_SCHEMA = "lingmiao.source-json-step-previews.v1";
private static final String IMPORTED_STATUS = "success";
private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
private final CadModelMapper cadModelMapper;
private final CadModelHistoryMapper cadModelHistoryMapper;
private final CosObjectStorageClient cosObjectStorageClient;
@Override
@Transactional
public ImportSummary importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
boolean uploadEnabled, boolean replaceExisting, long categoryId) {
if (categoryId <= 0) {
throw new IllegalArgumentException("categoryId 必须为正数");
}
Map<String, Path> sourceFiles = readSourceFiles(sourceDirectory);
List<ImportedModel> models = readArtifacts(artifactsDirectory, sourceFiles);
if (models.size() != sourceFiles.size()) {
throw new IllegalArgumentException("JSON 与完整产物数量不一致: json=" + sourceFiles.size() + ", artifacts=" + models.size());
}
if (!replaceExisting) {
ensureModelsDoNotExist(models);
}
if (uploadEnabled) {
for (ImportedModel model : models) {
uploadModelAssets(model, cosPrefix);
}
}
if (replaceExisting) {
clearExistingAnnotationData();
}
int historyCount = 0;
for (ImportedModel importedModel : models) {
CadModel model = createModel(importedModel, cosPrefix, categoryId);
cadModelMapper.insert(model);
if (model.getId() == null) {
throw new IllegalStateException("导入模型后未获得数据库 ID: " + importedModel.partName());
}
for (ImportedHistory importedHistory : importedModel.histories()) {
cadModelHistoryMapper.insert(createHistory(model.getId(), importedModel, importedHistory, cosPrefix));
historyCount++;
}
}
log.info("Processed SolidWorks dataset imported. modelCount={}, historyCount={}, artifactsDirectory={}",
models.size(), historyCount, artifactsDirectory.toAbsolutePath().normalize());
return new ImportSummary(models.size(), historyCount);
}
private Map<String, Path> readSourceFiles(Path sourceDirectory) {
requireDirectory(sourceDirectory, "JSON 数据集目录");
Map<String, Path> result = new HashMap<>();
try (Stream<Path> paths = Files.list(sourceDirectory)) {
for (Path file : paths.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json"))
.sorted().toList()) {
JsonNode root = readJson(file);
if (!SUPPORTED_SCHEMA.equals(text(root, "schema"))) {
throw new IllegalArgumentException("不支持的 JSON schema: " + file.getFileName());
}
String partName = requireText(text(root, "part_name"), "JSON 缺少 part_name: " + file.getFileName());
if (result.put(partName, file) != null) {
throw new IllegalArgumentException("JSON 数据集包含重复 part_name: " + partName);
}
}
} catch (IOException exception) {
throw new IllegalStateException("读取 JSON 数据集目录失败: " + sourceDirectory, exception);
}
return result;
}
private List<ImportedModel> readArtifacts(Path artifactsDirectory, Map<String, Path> sourceFiles) {
requireDirectory(artifactsDirectory, "转换产物目录");
List<Path> manifests;
try (Stream<Path> paths = Files.walk(artifactsDirectory)) {
manifests = paths.filter(Files::isRegularFile)
.filter(path -> path.endsWith(Path.of("step-previews", "manifest.json")))
.sorted().toList();
} catch (IOException exception) {
throw new IllegalStateException("读取转换产物目录失败: " + artifactsDirectory, exception);
}
List<ImportedModel> models = new ArrayList<>();
Set<String> partNames = new HashSet<>();
for (Path manifestPath : manifests) {
Path versionRoot = manifestPath.getParent().getParent();
JsonNode sourceRoot = readJson(versionRoot.resolve("source.feature-tree.json"));
String partName = requireText(text(sourceRoot, "part_name"), "转换产物缺少 part_name: " + versionRoot);
if (!sourceFiles.containsKey(partName)) {
continue;
}
if (!partNames.add(partName)) {
throw new IllegalArgumentException("转换产物无法唯一匹配 JSON: " + partName);
}
requireFiles(versionRoot, "source-json-rebuilt.step", "scene.glb", "topology.json", "thumbnail.svg",
"source-json-feature-geometry-map.json", "source-json-rebuild-report.json");
JsonNode manifest = readJson(manifestPath);
if (!PREVIEW_SCHEMA.equals(text(manifest, "schemaVersion")) || manifest.path("failedCount").asInt(-1) != 0) {
throw new IllegalArgumentException("步骤预览未全部成功: " + manifestPath);
}
List<ImportedHistory> histories = new ArrayList<>();
JsonNode steps = manifest.path("steps");
if (!steps.isArray() || steps.size() != manifest.path("readyCount").asInt(-1)) {
throw new IllegalArgumentException("步骤预览数量不一致: " + manifestPath);
}
for (JsonNode step : steps) {
int order = step.path("order").asInt();
if (order <= 0 || !"ready".equals(text(step, "status"))) {
throw new IllegalArgumentException("步骤预览状态非法: " + manifestPath);
}
String directory = "%04d".formatted(order);
requireFiles(versionRoot.resolve("step-previews").resolve(directory), "step.step", "scene.glb", "topology.json");
histories.add(new ImportedHistory(
trimTo(requireText(text(step, "operationKey"), "步骤缺少 operationKey"), 128),
order,
trimTo(requireText(text(step, "name"), "步骤缺少 name"), 255),
trimTo(requireText(text(step, "type"), "步骤缺少 type"), 64),
directory
));
}
models.add(new ImportedModel(trimTo(partName, 255), versionRoot, histories));
}
models.sort(Comparator.comparing(ImportedModel::partName));
return models;
}
private void ensureModelsDoNotExist(List<ImportedModel> models) {
Set<String> modelNames = models.stream().map(ImportedModel::partName).collect(java.util.stream.Collectors.toSet());
Set<String> sourceFilenames = models.stream()
.map(model -> model.partName() + ".SLDPRT")
.collect(java.util.stream.Collectors.toSet());
Long count = cadModelMapper.selectCount(Wrappers.lambdaQuery(CadModel.class)
.in(CadModel::getModelName, modelNames)
.or()
.in(CadModel::getSourceSwFilename, sourceFilenames));
if (count != null && count > 0) {
throw new IllegalArgumentException("追加导入包含远程已存在模型,拒绝写入: duplicateCount=" + count);
}
}
private void uploadModelAssets(ImportedModel model, String cosPrefix) {
List<Path> files;
try (Stream<Path> paths = Files.walk(model.versionRoot())) {
files = paths.filter(Files::isRegularFile).sorted().toList();
} catch (IOException exception) {
throw new IllegalStateException("读取模型产物失败: " + model.partName(), exception);
}
for (Path file : files) {
String objectKey = objectKey(cosPrefix, model, model.versionRoot().relativize(file).toString());
try (InputStream inputStream = Files.newInputStream(file)) {
cosObjectStorageClient.putObject(objectKey, inputStream, Files.size(file), contentType(file));
} catch (IOException exception) {
throw new IllegalStateException("上传 COS 失败: " + file, exception);
}
}
log.info("Uploaded model assets to COS. partName={}, fileCount={}", model.partName(), files.size());
}
private void clearExistingAnnotationData() {
int functionRelations = jdbcTemplate.update("DELETE FROM cad_function_structure_rel");
int structureRelations = jdbcTemplate.update("DELETE FROM cad_structure_history_rel");
int historyAnnotations = jdbcTemplate.update("DELETE FROM cad_model_history_annotation");
int functions = jdbcTemplate.update("DELETE FROM cad_function");
int structures = jdbcTemplate.update("DELETE FROM cad_structure");
int histories = jdbcTemplate.update("DELETE FROM cad_model_history");
int models = jdbcTemplate.update("DELETE FROM cad_model");
log.warn("Cleared CAD annotation data. models={}, histories={}, annotations={}, structures={}, functions={}, structureRelations={}, functionRelations={}",
models, histories, historyAnnotations, structures, functions, structureRelations, functionRelations);
}
private CadModel createModel(ImportedModel source, String cosPrefix, long categoryId) {
CadModel model = new CadModel();
model.setCategoryId(categoryId);
model.setModelName(source.partName());
model.setSourceSwFilename(trimTo(source.partName() + ".SLDPRT", 255));
model.setSourceSwPath(trimTo(objectKey(cosPrefix, source, "source-json-rebuilt.step"), 1024));
model.setFeatureTreeJsonPath(trimTo(objectKey(cosPrefix, source, "source.feature-tree.json"), 1024));
model.setViewPath(trimTo(objectKey(cosPrefix, source, "scene.glb"), 1024));
model.setThumbnailPath(trimTo(objectKey(cosPrefix, source, "thumbnail.svg"), 1024));
model.setApplicationScenario("");
model.setMaterial("");
model.setManufacturingMethod("");
model.setUnit("mm");
model.setDesignDescription("由 SolidWorks JSON 完整重建流程导入");
model.setConvertStatus(IMPORTED_STATUS);
model.setCreatedAt(LocalDateTime.now());
model.setUpdatedAt(LocalDateTime.now());
return model;
}
private CadModelHistory createHistory(Long modelId, ImportedModel model, ImportedHistory source, String cosPrefix) {
String base = "step-previews/" + source.directory() + "/";
CadModelHistory history = new CadModelHistory();
history.setModelId(modelId);
history.setSourceFeatureId(source.featureId());
history.setHistoryIndex(source.historyIndex());
history.setHistoryName(source.name());
history.setOperationType(source.operationType());
history.setStepPath(trimTo(objectKey(cosPrefix, model, base + "step.step"), 1024));
history.setGlbPath(trimTo(objectKey(cosPrefix, model, base + "scene.glb"), 1024));
history.setTopologyPath(trimTo(objectKey(cosPrefix, model, base + "topology.json"), 1024));
history.setConvertStatus(IMPORTED_STATUS);
history.setCreatedAt(LocalDateTime.now());
history.setUpdatedAt(LocalDateTime.now());
return history;
}
private JsonNode readJson(Path file) {
try {
JsonNode root = objectMapper.readTree(file.toFile());
if (root == null || !root.isObject()) {
throw new IllegalArgumentException("JSON 根节点必须是对象: " + file);
}
return root;
} catch (IOException exception) {
throw new IllegalArgumentException("读取 JSON 失败: " + file, exception);
}
}
private void requireFiles(Path root, String... relativePaths) {
for (String relativePath : relativePaths) {
if (!Files.isRegularFile(root.resolve(relativePath))) {
throw new IllegalArgumentException("转换产物缺失: " + root.resolve(relativePath));
}
}
}
private void requireDirectory(Path directory, String label) {
if (directory == null || !Files.isDirectory(directory)) {
throw new IllegalArgumentException(label + "不存在或不是目录: " + directory);
}
}
private String objectKey(String cosPrefix, ImportedModel model, String relativePath) {
String prefix = StringUtils.hasText(cosPrefix) ? cosPrefix.trim() : "datasets/solidworks-rebuild-v1";
String normalizedRelativePath = relativePath.replace('\\', '/').replaceAll("^/+|/+$", "");
return prefix.replaceAll("/+$", "") + "/" + model.partName() + "/" + normalizedRelativePath;
}
private String contentType(Path file) {
String name = file.getFileName().toString().toLowerCase(Locale.ROOT);
if (name.endsWith(".json")) return "application/json";
if (name.endsWith(".glb")) return "model/gltf-binary";
if (name.endsWith(".svg")) return "image/svg+xml";
if (name.endsWith(".step") || name.endsWith(".stp")) return "model/step";
if (name.endsWith(".log")) return "text/plain";
return "application/octet-stream";
}
private static String text(JsonNode node, String fieldName) {
JsonNode value = node.path(fieldName);
return value.isTextual() ? value.asText().trim() : "";
}
private static String requireText(String value, String message) {
if (!StringUtils.hasText(value)) throw new IllegalArgumentException(message);
return value;
}
private static String trimTo(String value, int maximumLength) {
String normalized = value == null ? "" : value.trim();
return normalized.length() <= maximumLength ? normalized : normalized.substring(0, maximumLength);
}
private record ImportedModel(String partName, Path versionRoot, List<ImportedHistory> histories) {
}
private record ImportedHistory(String featureId, int historyIndex, String name, String operationType, String directory) {
}
}
+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
+65
View File
@@ -0,0 +1,65 @@
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:}
session-token: ${APP_COS_SESSION_TOKEN:}
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}
json-dataset-import:
enabled: ${APP_JSON_DATASET_IMPORT_ENABLED:false}
source-directory: ${APP_JSON_DATASET_IMPORT_SOURCE_DIRECTORY:}
artifacts-directory: ${APP_JSON_DATASET_IMPORT_ARTIFACTS_DIRECTORY:}
cos-prefix: ${APP_JSON_DATASET_IMPORT_COS_PREFIX:datasets/solidworks-rebuild-v1}
upload-enabled: ${APP_JSON_DATASET_IMPORT_UPLOAD_ENABLED:true}
replace-existing: ${APP_JSON_DATASET_IMPORT_REPLACE_EXISTING:true}
category-id: ${APP_JSON_DATASET_IMPORT_CATEGORY_ID:1}
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,363 @@
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.CadAnnotationExportResponse;
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 exportCurrentAnnotationReturnsAiReadablePackage() throws Exception {
given(cadAnnotationService.exportCurrentAnnotation(1L)).willReturn(sampleExport());
mockMvc.perform(get("/api/cad-models/1/export/annotation"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("OK"))
.andExpect(jsonPath("$.data.schemaVersion").value("cadlabel.current-annotation.v1"))
.andExpect(jsonPath("$.data.functions[0].structureIds[0]").value(100))
.andExpect(jsonPath("$.data.structures[0].featureSteps[0].feature.sourceFeature.sourceFeatureId").value("feat_001"))
.andExpect(jsonPath("$.data.relations[0].type").value("implemented_by"));
verify(cadAnnotationService).exportCurrentAnnotation(1L);
}
@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 CadAnnotationExportResponse sampleExport() {
CadAnnotationExportResponse.Feature feature = new CadAnnotationExportResponse.Feature(
10L,
"凸台-拉伸1",
"extrude_add",
1,
new CadAnnotationExportResponse.SourceFeature(
"feat_001",
"bound",
"steps/1.step",
"glbs/1.glb",
"topologies/1.json"
),
new CadAnnotationExportResponse.Annotation("创建底座", "覆盖底面", "人工确认", LocalDateTime.of(2026, 7, 8, 10, 5, 0))
);
return new CadAnnotationExportResponse(
"cadlabel.current-annotation.v1",
LocalDateTime.of(2026, 7, 14, 10, 0, 0),
"AI training annotation",
new CadAnnotationExportResponse.Model(1L, "轴承座", 7L, "工业夹具", "铝合金", "CNC", "mm"),
new CadAnnotationExportResponse.Source("轴承座.sldprt", "sw/1.sldprt", "features/1.json", "sourceFeatureId binds source JSON"),
new CadAnnotationExportResponse.Requirement("需求描述"),
"设计说明",
List.of(new CadAnnotationExportResponse.Function(200L, "固定功能", "mounting", "固定外部零件", "人工确认", List.of(100L))),
List.of(new CadAnnotationExportResponse.Structure(
100L,
"加强筋",
"rib",
"提高强度",
"多段拉伸",
"受力集中",
"人工确认",
List.of(200L),
List.of(new CadAnnotationExportResponse.FeatureStep("structure_history:1000", 0, feature))
)),
List.of(feature),
List.of(),
List.of(new CadAnnotationExportResponse.Relation(
"structure_history:1000",
"implemented_by",
"structure:100",
"history:10",
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,193 @@
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.dto.cad.annotation.CadModelHistoryAnnotationResponse;
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("轴承座"))
.andExpect(jsonPath("$.data.items[0].thumbnailPath").value("thumbnails/015133.svg"));
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.thumbnailPath").value("thumbnails/015133.svg"))
.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,
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),
new CadModelHistoryAnnotationResponse(
100L,
1L,
10L,
"拉伸形成主体",
"决定主体厚度",
"已复核",
LocalDateTime.of(2026, 7, 8, 10, 1, 0),
LocalDateTime.of(2026, 7, 8, 10, 2, 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"))
.andExpect(jsonPath("$.data[0].annotation.modelingDescription").value("拉伸形成主体"))
.andExpect(jsonPath("$.data[0].annotation.supplementDescription").value("决定主体厚度"))
.andExpect(jsonPath("$.data[0].annotation.remark").value("已复核"));
}
private static CadModelResponse sampleModel(Long id, String modelName) {
return new CadModelResponse(
id,
7L,
modelName,
"015133.sldprt",
"sw/015133.sldprt",
"features/015133.json",
"views/015133",
"thumbnails/015133.svg",
"工业夹具",
"铝合金",
"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,539 @@
package com.cadlabel.service.impl;
import com.cadlabel.common.exception.BizException;
import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse;
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 '',
thumbnail_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 exportCurrentAnnotationKeepsFunctionStructureAndSourceFeatureBinding() {
cadAnnotationService.saveHistoryAnnotation(1L, 10L,
new SaveHistoryAnnotationRequest("创建主体基体", "作为后续孔和倒角的稳定基准", "人工复核"));
CadStructureResponse structure = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
"主体承载结构",
"base_body",
"提供承载基体",
"先拉伸主体再加工细节",
"先稳定主基准,降低后续特征依赖",
"关键结构",
List.of(10L)
));
CadFunctionResponse function = cadAnnotationService.createFunction(1L, new CreateCadFunctionRequest(
"安装承载功能",
"mounting",
"实现外部零件安装与承载",
"主功能",
List.of(structure.id())
));
CadAnnotationExportResponse exported = cadAnnotationService.exportCurrentAnnotation(1L);
assertThat(exported.schemaVersion()).isEqualTo("cadlabel.current-annotation.v1");
assertThat(exported.source().featureTreeJsonPath()).isEqualTo("features/1.json");
assertThat(exported.functions()).singleElement().satisfies(item -> {
assertThat(item.id()).isEqualTo(function.id());
assertThat(item.structureIds()).containsExactly(structure.id());
});
assertThat(exported.structures()).singleElement().satisfies(item -> {
assertThat(item.id()).isEqualTo(structure.id());
assertThat(item.functionIds()).containsExactly(function.id());
assertThat(item.featureSteps()).singleElement().satisfies(step -> {
assertThat(step.feature().historyId()).isEqualTo(10L);
assertThat(step.feature().sourceFeature().sourceFeatureId()).isEqualTo("feat_001");
assertThat(step.feature().annotation().modelingIntent()).isEqualTo("创建主体基体");
});
});
assertThat(exported.unassignedHistoryIds()).containsExactly(11L);
assertThat(exported.relations()).extracting(CadAnnotationExportResponse.Relation::type)
.containsExactlyInAnyOrder("realized_by", "implemented_by");
}
@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,314 @@
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_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 '',
thumbnail_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_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_id)
)
""");
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,
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,
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,
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");
jdbcTemplate.update("""
INSERT INTO cad_model_history_annotation (
id, model_id, history_id, modeling_description,
supplement_description, remark, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
100L,
1L,
10L,
"拉伸形成主体",
"决定主体厚度",
"已复核",
"2026-07-08 10:01:00",
"2026-07-08 10:02:00"
);
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");
assertThat(histories.getFirst().annotation()).isNotNull();
assertThat(histories.getFirst().annotation().modelingDescription()).isEqualTo("拉伸形成主体");
assertThat(histories.get(1).annotation()).isNull();
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,110 @@
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,141 @@
package com.cadlabel.service.impl;
import com.cadlabel.entity.CadModel;
import com.cadlabel.entity.CadModelHistory;
import com.cadlabel.mapper.CadModelHistoryMapper;
import com.cadlabel.mapper.CadModelMapper;
import com.cadlabel.service.JsonDatasetImportService;
import com.cadlabel.service.client.CosObjectStorageClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SolidWorksJsonDatasetImportServiceTest {
@Mock
private CadModelMapper cadModelMapper;
@Mock
private CadModelHistoryMapper cadModelHistoryMapper;
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private CosObjectStorageClient cosObjectStorageClient;
private SolidWorksJsonDatasetImportService service;
@TempDir
private Path temporaryDirectory;
private Path sourceDirectory;
private Path artifactsDirectory;
@BeforeEach
void setUp() {
sourceDirectory = temporaryDirectory.resolve("source");
artifactsDirectory = temporaryDirectory.resolve("artifacts");
service = new SolidWorksJsonDatasetImportService(
new ObjectMapper(),
jdbcTemplate,
cadModelMapper,
cadModelHistoryMapper,
cosObjectStorageClient
);
}
@Test
void replaceDatasetClearsOldAnnotationDataAndImportsFeatureHistories() throws Exception {
Files.createDirectories(sourceDirectory);
Files.writeString(sourceDirectory.resolve("demo.solidworks_rebuild_extract.json"), """
{
"schema": "solidworks.rebuild_extract.v1",
"part_name": "demo",
"features": [
{"id": "feat_000", "name": "基体拉伸", "type": "extrude", "type_name": "BossExtrude", "source_feature": {"index": 0}},
{"id": "feat_001", "name": "倒角", "type": "chamfer", "source_feature": {"index": 1}}
]
}
""");
Path versionRoot = artifactsDirectory.resolve("model-id/v1");
Files.createDirectories(versionRoot.resolve("step-previews/0001"));
Files.createDirectories(versionRoot.resolve("step-previews/0002"));
Files.copy(sourceDirectory.resolve("demo.solidworks_rebuild_extract.json"), versionRoot.resolve("source.feature-tree.json"));
for (String file : new String[]{"source-json-rebuilt.step", "scene.glb", "topology.json", "thumbnail.svg",
"source-json-feature-geometry-map.json", "source-json-rebuild-report.json"}) {
Files.writeString(versionRoot.resolve(file), "asset");
}
for (String directory : new String[]{"0001", "0002"}) {
for (String file : new String[]{"step.step", "scene.glb", "topology.json"}) {
Files.writeString(versionRoot.resolve("step-previews").resolve(directory).resolve(file), "asset");
}
}
Files.writeString(versionRoot.resolve("step-previews/manifest.json"), """
{
"schemaVersion": "lingmiao.source-json-step-previews.v1",
"readyCount": 2,
"failedCount": 0,
"steps": [
{"order": 1, "operationKey": "feat_000", "name": "基体拉伸", "type": "extrude_add", "status": "ready"},
{"order": 2, "operationKey": "feat_001", "name": "倒角", "type": "chamfer", "status": "ready"}
]
}
""");
AtomicLong ids = new AtomicLong(100L);
doAnswer(invocation -> {
CadModel model = invocation.getArgument(0);
model.setId(ids.getAndIncrement());
return 1;
}).when(cadModelMapper).insert(any(CadModel.class));
JsonDatasetImportService.ImportSummary summary = service.importDataset(
sourceDirectory, artifactsDirectory, "dataset-test", true, true, 7L);
assertThat(summary).isEqualTo(new JsonDatasetImportService.ImportSummary(1, 2));
ArgumentCaptor<CadModel> modelCaptor = ArgumentCaptor.forClass(CadModel.class);
verify(cadModelMapper).insert(modelCaptor.capture());
assertThat(modelCaptor.getValue().getCategoryId()).isEqualTo(7L);
assertThat(modelCaptor.getValue().getModelName()).isEqualTo("demo");
assertThat(modelCaptor.getValue().getFeatureTreeJsonPath()).isEqualTo("dataset-test/demo/source.feature-tree.json");
assertThat(modelCaptor.getValue().getSourceSwPath()).isEqualTo("dataset-test/demo/source-json-rebuilt.step");
assertThat(modelCaptor.getValue().getViewPath()).isEqualTo("dataset-test/demo/scene.glb");
assertThat(modelCaptor.getValue().getThumbnailPath()).isEqualTo("dataset-test/demo/thumbnail.svg");
ArgumentCaptor<CadModelHistory> historyCaptor = ArgumentCaptor.forClass(CadModelHistory.class);
verify(cadModelHistoryMapper, org.mockito.Mockito.times(2)).insert(historyCaptor.capture());
assertThat(historyCaptor.getAllValues())
.extracting(CadModelHistory::getSourceFeatureId, CadModelHistory::getHistoryIndex,
CadModelHistory::getHistoryName, CadModelHistory::getOperationType)
.containsExactly(
org.assertj.core.groups.Tuple.tuple("feat_000", 1, "基体拉伸", "extrude_add"),
org.assertj.core.groups.Tuple.tuple("feat_001", 2, "倒角", "chamfer")
);
assertThat(historyCaptor.getAllValues().getFirst().getStepPath())
.isEqualTo("dataset-test/demo/step-previews/0001/step.step");
verify(cosObjectStorageClient, org.mockito.Mockito.atLeastOnce())
.putObject(anyString(), any(InputStream.class), anyLong(), anyString());
verify(jdbcTemplate).update("DELETE FROM cad_function_structure_rel");
verify(jdbcTemplate).update("DELETE FROM cad_structure_history_rel");
verify(jdbcTemplate).update("DELETE FROM cad_model_history_annotation");
verify(jdbcTemplate).update("DELETE FROM cad_function");
verify(jdbcTemplate).update("DELETE FROM cad_structure");
verify(jdbcTemplate).update("DELETE FROM cad_model_history");
verify(jdbcTemplate).update("DELETE FROM cad_model");
}
}
@@ -0,0 +1 @@
org.mockito.internal.creation.bytebuddy.SubclassByteBuddyMockMaker