Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aaabd4270 | |||
| 17964d4ee7 | |||
| c0a5fb8e26 | |||
| 43545aa715 | |||
| 82255a6feb | |||
| bc18930b7a | |||
| 3f30d574d6 | |||
| 234a166924 |
+5
-18
@@ -1,18 +1,5 @@
|
||||
# Build and Release Folders
|
||||
bin-debug/
|
||||
bin-release/
|
||||
[Oo]bj/
|
||||
[Bb]in/
|
||||
|
||||
# Other files and folders
|
||||
.settings/
|
||||
|
||||
# Executables
|
||||
*.swf
|
||||
*.air
|
||||
*.ipa
|
||||
*.apk
|
||||
|
||||
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
|
||||
# should NOT be excluded as they contain compiler settings and other important
|
||||
# information for Eclipse / Flash Builder.
|
||||
target/
|
||||
logs/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## 代码实现原则
|
||||
- 优先保证业务正确性、可维护性、可读性,再考虑炫技。
|
||||
- 修改代码时忽略.sql文件,如果遇到需要改表结构,直接发我sql。
|
||||
## 日志规范
|
||||
- 关键业务节点必须补充必要日志,方便线上排查。
|
||||
- 不要打印密码、Token、手机号、身份证、支付密钥等敏感信息。
|
||||
- 异常日志必须包含足够定位问题的信息。
|
||||
## 回复规范
|
||||
- 永远用中文回复。
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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`,便于后端按请求链路定位日志。
|
||||
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.4</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.cadlabel</groupId>
|
||||
<artifactId>cadlabel</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>cadlabel</name>
|
||||
<description>CadLabel backend service.</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<mybatis-plus.version>3.5.11</mybatis-plus.version>
|
||||
<springdoc.version>2.8.6</springdoc.version>
|
||||
<tencentcloud.cos.sdk.version>5.6.246</tencentcloud.cos.sdk.version>
|
||||
<tencentcloud.cos.sts.sdk.version>3.1.1</tencentcloud.cos.sts.sdk.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos_api</artifactId>
|
||||
<version>${tencentcloud.cos.sdk.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos-sts_api</artifactId>
|
||||
<version>${tencentcloud.cos.sts.sdk.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>public</id>
|
||||
<name>aliyun nexus</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
</repository>
|
||||
</repositories>
|
||||
</project>
|
||||
@@ -0,0 +1,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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
datasource:
|
||||
url: ${APP_DB_URL:jdbc:mysql://39.105.93.138:3306/cadlabel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true}
|
||||
username: ${APP_DB_USERNAME:root}
|
||||
password: ${APP_DB_PASSWORD:mysql_QRWm4M}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
pool-name: CadLabelDevHikariCP
|
||||
minimum-idle: 1
|
||||
maximum-pool-size: 10
|
||||
connection-timeout: 30000
|
||||
validation-timeout: 5000
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.cadlabel: DEBUG
|
||||
@@ -0,0 +1,37 @@
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
datasource:
|
||||
url: ${APP_DB_URL}
|
||||
username: ${APP_DB_USERNAME}
|
||||
password: ${APP_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
hikari:
|
||||
pool-name: CadLabelProdHikariCP
|
||||
minimum-idle: 5
|
||||
maximum-pool-size: 20
|
||||
connection-timeout: 30000
|
||||
validation-timeout: 5000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: false
|
||||
swagger-ui:
|
||||
enabled: false
|
||||
|
||||
server:
|
||||
tomcat:
|
||||
threads:
|
||||
min-spare: 10
|
||||
max: 200
|
||||
accept-count: 100
|
||||
connection-timeout: 10s
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.cadlabel: INFO
|
||||
org.springframework.web: INFO
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user