diff --git a/src/main/java/com/cadlabel/controller/CadAnnotationController.java b/src/main/java/com/cadlabel/controller/CadAnnotationController.java
new file mode 100644
index 0000000..c744b5b
--- /dev/null
+++ b/src/main/java/com/cadlabel/controller/CadAnnotationController.java
@@ -0,0 +1,146 @@
+package com.cadlabel.controller;
+
+import com.cadlabel.common.api.ApiResponse;
+import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
+import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
+import com.cadlabel.dto.cad.annotation.CadStructureResponse;
+import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
+import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
+import com.cadlabel.service.CadAnnotationService;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Positive;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.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;
+
+ /**
+ * 保存建模历史步骤标注。
+ *
+ *
同一个建模历史只能有一条标注;重复保存时按 historyId 覆盖原有标注内容。
+ */
+ @PostMapping("/histories/{historyId}/annotation")
+ public ApiResponse saveHistoryAnnotation(
+ @PathVariable @Positive Long modelId,
+ @PathVariable @Positive Long historyId,
+ @Valid @RequestBody(required = false) SaveHistoryAnnotationRequest request
+ ) {
+ return ApiResponse.success(cadAnnotationService.saveHistoryAnnotation(modelId, historyId, request));
+ }
+
+ /**
+ * 创建 CAD 结构。
+ *
+ * 创建时必须选择至少一个建模历史步骤,关系排序按 historyIds 请求顺序生成。
+ */
+ @PostMapping("/structures")
+ public ApiResponse createStructure(
+ @PathVariable @Positive Long modelId,
+ @Valid @RequestBody CreateCadStructureRequest request
+ ) {
+ return ApiResponse.success(cadAnnotationService.createStructure(modelId, request));
+ }
+
+ /**
+ * 修改 CAD 结构。
+ *
+ * 普通字段支持部分更新;传入 historyIds 时按最终集合全量替换结构下的建模历史关系。
+ */
+ @PostMapping("/structures/{structureId}")
+ public ApiResponse updateStructure(
+ @PathVariable @Positive Long modelId,
+ @PathVariable @Positive Long structureId,
+ @Valid @RequestBody(required = false) UpdateCadStructureRequest request
+ ) {
+ return ApiResponse.success(cadAnnotationService.updateStructure(modelId, structureId, request));
+ }
+
+ /**
+ * 查询模型下的结构列表。
+ *
+ * 只返回未逻辑删除的结构,并携带结构关联的建模历史步骤。
+ */
+ @GetMapping("/structures")
+ public ApiResponse> listStructures(@PathVariable @Positive Long modelId) {
+ return ApiResponse.success(cadAnnotationService.listStructures(modelId));
+ }
+
+ /**
+ * 查询结构详情。
+ *
+ * 用于结构编辑回显,返回结构基础信息和已关联的建模历史步骤。
+ */
+ @GetMapping("/structures/{structureId}")
+ public ApiResponse getStructure(
+ @PathVariable @Positive Long modelId,
+ @PathVariable @Positive Long structureId
+ ) {
+ return ApiResponse.success(cadAnnotationService.getStructure(modelId, structureId));
+ }
+
+ /**
+ * 创建 CAD 功能。
+ *
+ * 创建时必须选择至少一个结构,关系排序按 structureIds 请求顺序生成。
+ */
+ @PostMapping("/functions")
+ public ApiResponse createFunction(
+ @PathVariable @Positive Long modelId,
+ @Valid @RequestBody CreateCadFunctionRequest request
+ ) {
+ return ApiResponse.success(cadAnnotationService.createFunction(modelId, request));
+ }
+
+ /**
+ * 修改 CAD 功能。
+ *
+ * 普通字段支持部分更新;传入 structureIds 时按最终集合全量替换功能下的结构关系。
+ */
+ @PostMapping("/functions/{functionId}")
+ public ApiResponse updateFunction(
+ @PathVariable @Positive Long modelId,
+ @PathVariable @Positive Long functionId,
+ @Valid @RequestBody(required = false) UpdateCadFunctionRequest request
+ ) {
+ return ApiResponse.success(cadAnnotationService.updateFunction(modelId, functionId, request));
+ }
+
+ /**
+ * 查询模型下的功能列表。
+ *
+ * 只返回未逻辑删除的功能,并携带功能关联的结构。
+ */
+ @GetMapping("/functions")
+ public ApiResponse> listFunctions(@PathVariable @Positive Long modelId) {
+ return ApiResponse.success(cadAnnotationService.listFunctions(modelId));
+ }
+
+ /**
+ * 查询功能详情。
+ *
+ * 用于功能编辑回显,返回功能基础信息和已关联的结构。
+ */
+ @GetMapping("/functions/{functionId}")
+ public ApiResponse getFunction(
+ @PathVariable @Positive Long modelId,
+ @PathVariable @Positive Long functionId
+ ) {
+ return ApiResponse.success(cadAnnotationService.getFunction(modelId, functionId));
+ }
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CadFunctionResponse.java b/src/main/java/com/cadlabel/dto/cad/annotation/CadFunctionResponse.java
new file mode 100644
index 0000000..8574acf
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/CadFunctionResponse.java
@@ -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 structureItems,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CadFunctionStructureItemResponse.java b/src/main/java/com/cadlabel/dto/cad/annotation/CadFunctionStructureItemResponse.java
new file mode 100644
index 0000000..4850a79
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/CadFunctionStructureItemResponse.java
@@ -0,0 +1,10 @@
+package com.cadlabel.dto.cad.annotation;
+
+public record CadFunctionStructureItemResponse(
+ Long relationId,
+ Long structureId,
+ String structureName,
+ String structureType,
+ Integer sortOrder
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CadModelHistoryAnnotationResponse.java b/src/main/java/com/cadlabel/dto/cad/annotation/CadModelHistoryAnnotationResponse.java
new file mode 100644
index 0000000..7fcfea3
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/CadModelHistoryAnnotationResponse.java
@@ -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
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CadStructureHistoryItemResponse.java b/src/main/java/com/cadlabel/dto/cad/annotation/CadStructureHistoryItemResponse.java
new file mode 100644
index 0000000..f0f5340
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/CadStructureHistoryItemResponse.java
@@ -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
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CadStructureResponse.java b/src/main/java/com/cadlabel/dto/cad/annotation/CadStructureResponse.java
new file mode 100644
index 0000000..bd677ce
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/CadStructureResponse.java
@@ -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 historyItems,
+ LocalDateTime createdAt,
+ LocalDateTime updatedAt
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CreateCadFunctionRequest.java b/src/main/java/com/cadlabel/dto/cad/annotation/CreateCadFunctionRequest.java
new file mode 100644
index 0000000..b577b18
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/CreateCadFunctionRequest.java
@@ -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
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CreateCadStructureRequest.java b/src/main/java/com/cadlabel/dto/cad/annotation/CreateCadStructureRequest.java
new file mode 100644
index 0000000..5666298
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/CreateCadStructureRequest.java
@@ -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
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/SaveHistoryAnnotationRequest.java b/src/main/java/com/cadlabel/dto/cad/annotation/SaveHistoryAnnotationRequest.java
new file mode 100644
index 0000000..98dfbf5
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/SaveHistoryAnnotationRequest.java
@@ -0,0 +1,8 @@
+package com.cadlabel.dto.cad.annotation;
+
+public record SaveHistoryAnnotationRequest(
+ String modelingDescription,
+ String supplementDescription,
+ String remark
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/UpdateCadFunctionRequest.java b/src/main/java/com/cadlabel/dto/cad/annotation/UpdateCadFunctionRequest.java
new file mode 100644
index 0000000..3bcf012
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/UpdateCadFunctionRequest.java
@@ -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
+) {
+}
diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/UpdateCadStructureRequest.java b/src/main/java/com/cadlabel/dto/cad/annotation/UpdateCadStructureRequest.java
new file mode 100644
index 0000000..eb7549d
--- /dev/null
+++ b/src/main/java/com/cadlabel/dto/cad/annotation/UpdateCadStructureRequest.java
@@ -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
+) {
+}
diff --git a/src/main/java/com/cadlabel/entity/CadFunction.java b/src/main/java/com/cadlabel/entity/CadFunction.java
new file mode 100644
index 0000000..00262be
--- /dev/null
+++ b/src/main/java/com/cadlabel/entity/CadFunction.java
@@ -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;
+}
diff --git a/src/main/java/com/cadlabel/entity/CadFunctionStructureRel.java b/src/main/java/com/cadlabel/entity/CadFunctionStructureRel.java
new file mode 100644
index 0000000..d8034b6
--- /dev/null
+++ b/src/main/java/com/cadlabel/entity/CadFunctionStructureRel.java
@@ -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;
+}
diff --git a/src/main/java/com/cadlabel/entity/CadModel.java b/src/main/java/com/cadlabel/entity/CadModel.java
index 0cc3925..fdbc494 100644
--- a/src/main/java/com/cadlabel/entity/CadModel.java
+++ b/src/main/java/com/cadlabel/entity/CadModel.java
@@ -10,22 +10,55 @@ import lombok.Data;
@TableName("cad_model")
public class CadModel {
+ /** 模型ID。 */
@TableId(type = IdType.AUTO)
private Long id;
+
+ /** 分类ID。 */
private Long categoryId;
+
+ /** 模型名称。 */
private String modelName;
+
+ /** 源 SolidWorks 文件名。 */
private String sourceSwFilename;
+
+ /** 源 SolidWorks 文件存储路径。 */
private String sourceSwPath;
+
+ /** 特征树 JSON 文件路径。 */
private String featureTreeJsonPath;
+
+ /** 模型视图资源路径。 */
private String viewPath;
+
+ /** 应用场景。 */
private String applicationScenario;
+
+ /** 材料。 */
private String material;
+
+ /** 制造方式。 */
private String manufacturingMethod;
+
+ /** 单位。 */
private String unit;
+
+ /** 用户需求描述。 */
private String userRequirement;
+
+ /** 整体设计说明。 */
private String designDescription;
+
+ /** 转换状态。 */
private String convertStatus;
+
+ /** 转换或处理错误信息。 */
private String errorMessage;
+
+ /** 创建时间。 */
private LocalDateTime createdAt;
+
+ /** 更新时间。 */
private LocalDateTime updatedAt;
}
diff --git a/src/main/java/com/cadlabel/entity/CadModelHistory.java b/src/main/java/com/cadlabel/entity/CadModelHistory.java
index d342b19..473b7ad 100644
--- a/src/main/java/com/cadlabel/entity/CadModelHistory.java
+++ b/src/main/java/com/cadlabel/entity/CadModelHistory.java
@@ -10,17 +10,40 @@ import lombok.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 convertStatus;
+
+ /** 转换或处理错误信息。 */
private String errorMessage;
+
+ /** 创建时间。 */
private LocalDateTime createdAt;
+
+ /** 更新时间。 */
private LocalDateTime updatedAt;
}
diff --git a/src/main/java/com/cadlabel/entity/CadModelHistoryAnnotation.java b/src/main/java/com/cadlabel/entity/CadModelHistoryAnnotation.java
new file mode 100644
index 0000000..267ded1
--- /dev/null
+++ b/src/main/java/com/cadlabel/entity/CadModelHistoryAnnotation.java
@@ -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;
+}
diff --git a/src/main/java/com/cadlabel/entity/CadStructure.java b/src/main/java/com/cadlabel/entity/CadStructure.java
new file mode 100644
index 0000000..792123b
--- /dev/null
+++ b/src/main/java/com/cadlabel/entity/CadStructure.java
@@ -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;
+}
diff --git a/src/main/java/com/cadlabel/entity/CadStructureHistoryRel.java b/src/main/java/com/cadlabel/entity/CadStructureHistoryRel.java
new file mode 100644
index 0000000..5d173a0
--- /dev/null
+++ b/src/main/java/com/cadlabel/entity/CadStructureHistoryRel.java
@@ -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;
+}
diff --git a/src/main/java/com/cadlabel/mapper/CadFunctionMapper.java b/src/main/java/com/cadlabel/mapper/CadFunctionMapper.java
new file mode 100644
index 0000000..b295eee
--- /dev/null
+++ b/src/main/java/com/cadlabel/mapper/CadFunctionMapper.java
@@ -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 {
+}
diff --git a/src/main/java/com/cadlabel/mapper/CadFunctionStructureRelMapper.java b/src/main/java/com/cadlabel/mapper/CadFunctionStructureRelMapper.java
new file mode 100644
index 0000000..0455401
--- /dev/null
+++ b/src/main/java/com/cadlabel/mapper/CadFunctionStructureRelMapper.java
@@ -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 {
+}
diff --git a/src/main/java/com/cadlabel/mapper/CadModelHistoryAnnotationMapper.java b/src/main/java/com/cadlabel/mapper/CadModelHistoryAnnotationMapper.java
new file mode 100644
index 0000000..a771d62
--- /dev/null
+++ b/src/main/java/com/cadlabel/mapper/CadModelHistoryAnnotationMapper.java
@@ -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 {
+}
diff --git a/src/main/java/com/cadlabel/mapper/CadStructureHistoryRelMapper.java b/src/main/java/com/cadlabel/mapper/CadStructureHistoryRelMapper.java
new file mode 100644
index 0000000..43d8983
--- /dev/null
+++ b/src/main/java/com/cadlabel/mapper/CadStructureHistoryRelMapper.java
@@ -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 {
+}
diff --git a/src/main/java/com/cadlabel/mapper/CadStructureMapper.java b/src/main/java/com/cadlabel/mapper/CadStructureMapper.java
new file mode 100644
index 0000000..544bc08
--- /dev/null
+++ b/src/main/java/com/cadlabel/mapper/CadStructureMapper.java
@@ -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 {
+}
diff --git a/src/main/java/com/cadlabel/service/CadAnnotationService.java b/src/main/java/com/cadlabel/service/CadAnnotationService.java
new file mode 100644
index 0000000..42670c5
--- /dev/null
+++ b/src/main/java/com/cadlabel/service/CadAnnotationService.java
@@ -0,0 +1,65 @@
+package com.cadlabel.service;
+
+import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
+import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
+import com.cadlabel.dto.cad.annotation.CadStructureResponse;
+import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
+import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
+import java.util.List;
+
+public interface CadAnnotationService {
+
+ /**
+ * 保存建模历史步骤标注。
+ *
+ * 如果指定 historyId 已存在标注,则覆盖原有标注文本;否则新建标注记录。
+ */
+ CadModelHistoryAnnotationResponse saveHistoryAnnotation(
+ Long modelId,
+ Long historyId,
+ SaveHistoryAnnotationRequest request
+ );
+
+ /**
+ * 创建结构并建立结构与建模历史步骤的关系。
+ */
+ CadStructureResponse createStructure(Long modelId, CreateCadStructureRequest request);
+
+ /**
+ * 修改结构基础信息,并在传入 historyIds 时全量替换结构与建模历史步骤的关系。
+ */
+ CadStructureResponse updateStructure(Long modelId, Long structureId, UpdateCadStructureRequest request);
+
+ /**
+ * 查询模型下未删除的结构列表。
+ */
+ List listStructures(Long modelId);
+
+ /**
+ * 查询结构详情。
+ */
+ CadStructureResponse getStructure(Long modelId, Long structureId);
+
+ /**
+ * 创建功能并建立功能与结构的关系。
+ */
+ CadFunctionResponse createFunction(Long modelId, CreateCadFunctionRequest request);
+
+ /**
+ * 修改功能基础信息,并在传入 structureIds 时全量替换功能与结构的关系。
+ */
+ CadFunctionResponse updateFunction(Long modelId, Long functionId, UpdateCadFunctionRequest request);
+
+ /**
+ * 查询模型下未删除的功能列表。
+ */
+ List listFunctions(Long modelId);
+
+ /**
+ * 查询功能详情。
+ */
+ CadFunctionResponse getFunction(Long modelId, Long functionId);
+}
diff --git a/src/main/java/com/cadlabel/service/impl/CadAnnotationServiceImpl.java b/src/main/java/com/cadlabel/service/impl/CadAnnotationServiceImpl.java
new file mode 100644
index 0000000..f74a628
--- /dev/null
+++ b/src/main/java/com/cadlabel/service/impl/CadAnnotationServiceImpl.java
@@ -0,0 +1,698 @@
+package com.cadlabel.service.impl;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.cadlabel.common.api.ApiCode;
+import com.cadlabel.common.exception.BizException;
+import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
+import com.cadlabel.dto.cad.annotation.CadFunctionStructureItemResponse;
+import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
+import com.cadlabel.dto.cad.annotation.CadStructureHistoryItemResponse;
+import com.cadlabel.dto.cad.annotation.CadStructureResponse;
+import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
+import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
+import com.cadlabel.entity.CadFunction;
+import com.cadlabel.entity.CadFunctionStructureRel;
+import com.cadlabel.entity.CadModel;
+import com.cadlabel.entity.CadModelHistory;
+import com.cadlabel.entity.CadModelHistoryAnnotation;
+import com.cadlabel.entity.CadStructure;
+import com.cadlabel.entity.CadStructureHistoryRel;
+import com.cadlabel.mapper.CadFunctionMapper;
+import com.cadlabel.mapper.CadFunctionStructureRelMapper;
+import com.cadlabel.mapper.CadModelHistoryAnnotationMapper;
+import com.cadlabel.mapper.CadModelHistoryMapper;
+import com.cadlabel.mapper.CadModelMapper;
+import com.cadlabel.mapper.CadStructureHistoryRelMapper;
+import com.cadlabel.mapper.CadStructureMapper;
+import com.cadlabel.service.CadAnnotationService;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+/**
+ * CAD 标注编排服务。
+ *
+ * 负责建模历史标注、结构、功能以及两层关系维护。
+ */
+@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;
+
+ /**
+ * 保存建模历史步骤标注。
+ *
+ * 先校验模型和历史步骤归属关系,再按 historyId 做插入或更新,保证前端重复保存是幂等的。
+ */
+ @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()));
+ }
+
+ /**
+ * 创建结构并绑定建模历史步骤。
+ *
+ * 结构基础记录和结构-历史关系在同一事务内写入,任一环节失败都会回滚。
+ */
+ @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 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());
+ }
+
+ /**
+ * 修改结构基础信息和历史步骤关系。
+ *
+ * 请求未传的普通字段保持原值;传入 historyIds 时会按请求数组全量替换关系。
+ */
+ @Override
+ @Transactional
+ public CadStructureResponse updateStructure(Long modelId, Long structureId, UpdateCadStructureRequest request) {
+ CadStructure structure = requireStructure(modelId, structureId);
+ if (request == null) {
+ return toStructureResponse(structure);
+ }
+
+ List updatedFields = structureUpdatedFields(request);
+ validateStructureUpdateRequest(request);
+ if (request.historyIds() != null) {
+ List 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);
+ }
+
+ /**
+ * 查询模型下的结构列表。
+ *
+ * 只查询 deleted=0 的结构,并为每个结构组装已关联的建模历史步骤。
+ */
+ @Override
+ public List 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();
+ }
+
+ /**
+ * 查询结构详情。
+ *
+ * 详情查询会校验结构归属当前模型,并排除已逻辑删除的结构。
+ */
+ @Override
+ public CadStructureResponse getStructure(Long modelId, Long structureId) {
+ return toStructureResponse(requireStructure(modelId, structureId));
+ }
+
+ /**
+ * 创建功能并绑定结构。
+ *
+ * 功能基础记录和功能-结构关系在同一事务内写入,关系排序按 structureIds 请求顺序生成。
+ */
+ @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 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());
+ }
+
+ /**
+ * 修改功能基础信息和结构关系。
+ *
+ * 请求未传的普通字段保持原值;传入 structureIds 时会按请求数组全量替换关系。
+ */
+ @Override
+ @Transactional
+ public CadFunctionResponse updateFunction(Long modelId, Long functionId, UpdateCadFunctionRequest request) {
+ CadFunction cadFunction = requireFunction(modelId, functionId);
+ if (request == null) {
+ return toFunctionResponse(cadFunction);
+ }
+
+ List updatedFields = functionUpdatedFields(request);
+ validateFunctionUpdateRequest(request);
+ if (request.structureIds() != null) {
+ List 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);
+ }
+
+ /**
+ * 查询模型下的功能列表。
+ *
+ * 只查询 deleted=0 的功能,并为每个功能组装已关联的结构。
+ */
+ @Override
+ public List 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();
+ }
+
+ /**
+ * 查询功能详情。
+ *
+ * 详情查询会校验功能归属当前模型,并排除已逻辑删除的功能。
+ */
+ @Override
+ public CadFunctionResponse getFunction(Long modelId, Long functionId) {
+ return toFunctionResponse(requireFunction(modelId, functionId));
+ }
+
+ /**
+ * 校验模型存在并返回模型记录。
+ */
+ 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 requireHistories(Long modelId, List historyIds) {
+ List 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 requireStructures(Long modelId, List structureIds) {
+ List 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));
+ }
+
+ /**
+ * 全量替换结构与建模历史步骤关系。
+ *
+ * 先删除结构原有关联,再按 historyIds 数组顺序写入 sortOrder。
+ */
+ private void replaceStructureHistoryRelations(Long modelId, Long structureId, List 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());
+ }
+
+ /**
+ * 全量替换功能与结构关系。
+ *
+ * 先删除功能原有关联,再按 structureIds 数组顺序写入 sortOrder。
+ */
+ private void replaceFunctionStructureRelations(Long modelId, Long functionId, List 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()
+ );
+ }
+
+ /**
+ * 将结构实体转换为接口响应。
+ *
+ * 响应中会按 sortOrder 升序组装结构关联的建模历史步骤。
+ */
+ private CadStructureResponse toStructureResponse(CadStructure structure) {
+ List relations = structureHistoryRelMapper.selectList(Wrappers.lambdaQuery(CadStructureHistoryRel.class)
+ .eq(CadStructureHistoryRel::getStructureId, structure.getId())
+ .orderByAsc(CadStructureHistoryRel::getSortOrder)
+ .orderByAsc(CadStructureHistoryRel::getId));
+ Map 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 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()
+ );
+ }
+
+ /**
+ * 将功能实体转换为接口响应。
+ *
+ * 响应中会按 sortOrder 升序组装功能关联的未删除结构。
+ */
+ private CadFunctionResponse toFunctionResponse(CadFunction cadFunction) {
+ List relations = functionStructureRelMapper.selectList(Wrappers.lambdaQuery(CadFunctionStructureRel.class)
+ .eq(CadFunctionStructureRel::getFunctionId, cadFunction.getId())
+ .orderByAsc(CadFunctionStructureRel::getSortOrder)
+ .orderByAsc(CadFunctionStructureRel::getId));
+ Map 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 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 validateIdList(String fieldName, List ids) {
+ if (ids == null || ids.isEmpty()) {
+ throw invalidRequest(fieldName + " 不能为空");
+ }
+ Set 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 structureUpdatedFields(UpdateCadStructureRequest request) {
+ List 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 functionUpdatedFields(UpdateCadFunctionRequest request) {
+ List 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 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);
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 8c72286..0ec5eba 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -14,7 +14,7 @@ spring:
time-zone: Asia/Shanghai
server:
- port: ${SERVER_PORT:8080}
+ port: ${SERVER_PORT:8891}
shutdown: graceful
forward-headers-strategy: framework
compression:
diff --git a/src/test/java/com/cadlabel/controller/CadAnnotationControllerTest.java b/src/test/java/com/cadlabel/controller/CadAnnotationControllerTest.java
new file mode 100644
index 0000000..cf0742c
--- /dev/null
+++ b/src/test/java/com/cadlabel/controller/CadAnnotationControllerTest.java
@@ -0,0 +1,281 @@
+package com.cadlabel.controller;
+
+import com.cadlabel.common.exception.GlobalExceptionHandler;
+import com.cadlabel.common.web.RequestIdFilter;
+import com.cadlabel.config.JacksonConfig;
+import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
+import com.cadlabel.dto.cad.annotation.CadFunctionStructureItemResponse;
+import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
+import com.cadlabel.dto.cad.annotation.CadStructureHistoryItemResponse;
+import com.cadlabel.dto.cad.annotation.CadStructureResponse;
+import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
+import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
+import com.cadlabel.service.CadAnnotationService;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(CadAnnotationController.class)
+@Import({GlobalExceptionHandler.class, RequestIdFilter.class, JacksonConfig.class})
+class CadAnnotationControllerTest {
+
+ @MockitoBean
+ private CadAnnotationService cadAnnotationService;
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void saveHistoryAnnotationReturnsUnifiedResponse() throws Exception {
+ given(cadAnnotationService.saveHistoryAnnotation(eq(1L), eq(10L), any()))
+ .willReturn(sampleAnnotation());
+
+ mockMvc.perform(post("/api/cad-models/1/histories/10/annotation")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "modelingDescription": "创建底座拉伸",
+ "supplementDescription": "范围覆盖底面",
+ "remark": "人工确认"
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("OK"))
+ .andExpect(jsonPath("$.data.historyId").value(10))
+ .andExpect(jsonPath("$.data.modelingDescription").value("创建底座拉伸"));
+
+ verify(cadAnnotationService).saveHistoryAnnotation(1L, 10L, new SaveHistoryAnnotationRequest(
+ "创建底座拉伸",
+ "范围覆盖底面",
+ "人工确认"
+ ));
+ }
+
+ @Test
+ void createStructureBindsHistoryIdsAndReturnsRelations() throws Exception {
+ given(cadAnnotationService.createStructure(eq(1L), any())).willReturn(sampleStructure(100L, "加强筋"));
+
+ mockMvc.perform(post("/api/cad-models/1/structures")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "structureName": "加强筋",
+ "structureType": "rib",
+ "purpose": "提高强度",
+ "solution": "多段拉伸",
+ "reason": "受力集中",
+ "remark": "优先复核",
+ "historyIds": [10, 11]
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value("OK"))
+ .andExpect(jsonPath("$.data.id").value(100))
+ .andExpect(jsonPath("$.data.structureName").value("加强筋"))
+ .andExpect(jsonPath("$.data.historyItems[0].historyId").value(10))
+ .andExpect(jsonPath("$.data.historyItems[0].sortOrder").value(0));
+
+ verify(cadAnnotationService).createStructure(1L, new CreateCadStructureRequest(
+ "加强筋",
+ "rib",
+ "提高强度",
+ "多段拉伸",
+ "受力集中",
+ "优先复核",
+ List.of(10L, 11L)
+ ));
+ }
+
+ @Test
+ void updateStructureAcceptsPartialJson() throws Exception {
+ given(cadAnnotationService.updateStructure(eq(1L), eq(100L), any()))
+ .willReturn(sampleStructure(100L, "加强筋-修改"));
+
+ mockMvc.perform(post("/api/cad-models/1/structures/100")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "structureName": "加强筋-修改",
+ "historyIds": [11, 10]
+ }
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.structureName").value("加强筋-修改"));
+
+ verify(cadAnnotationService).updateStructure(1L, 100L, new UpdateCadStructureRequest(
+ "加强筋-修改",
+ null,
+ null,
+ null,
+ null,
+ null,
+ List.of(11L, 10L)
+ ));
+ }
+
+ @Test
+ void listAndGetStructuresReturnUnifiedResponses() throws Exception {
+ given(cadAnnotationService.listStructures(1L)).willReturn(List.of(sampleStructure(100L, "加强筋")));
+ given(cadAnnotationService.getStructure(1L, 100L)).willReturn(sampleStructure(100L, "加强筋"));
+
+ mockMvc.perform(get("/api/cad-models/1/structures"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].id").value(100));
+
+ mockMvc.perform(get("/api/cad-models/1/structures/100"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").value(100));
+ }
+
+ @Test
+ void 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 createStructureRejectsEmptyHistoryIdsBeforeService() throws Exception {
+ mockMvc.perform(post("/api/cad-models/1/structures")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "structureName": "加强筋",
+ "historyIds": []
+ }
+ """))
+ .andExpect(status().isUnprocessableEntity())
+ .andExpect(jsonPath("$.code").value("INVALID_REQUEST"));
+ }
+
+ @Test
+ void createFunctionRejectsEmptyStructureIdsBeforeService() throws Exception {
+ mockMvc.perform(post("/api/cad-models/1/functions")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "functionName": "固定功能",
+ "structureIds": []
+ }
+ """))
+ .andExpect(status().isUnprocessableEntity())
+ .andExpect(jsonPath("$.code").value("INVALID_REQUEST"));
+ }
+
+ private static CadModelHistoryAnnotationResponse sampleAnnotation() {
+ return new CadModelHistoryAnnotationResponse(
+ 1L,
+ 1L,
+ 10L,
+ "创建底座拉伸",
+ "范围覆盖底面",
+ "人工确认",
+ LocalDateTime.of(2026, 7, 8, 10, 0, 0),
+ LocalDateTime.of(2026, 7, 8, 10, 5, 0)
+ );
+ }
+
+ private static CadStructureResponse sampleStructure(Long id, String structureName) {
+ return new CadStructureResponse(
+ id,
+ 1L,
+ structureName,
+ "rib",
+ "提高强度",
+ "多段拉伸",
+ "受力集中",
+ "优先复核",
+ List.of(new CadStructureHistoryItemResponse(1000L, 10L, 1, "凸台-拉伸1", "extrude_add", 0)),
+ LocalDateTime.of(2026, 7, 8, 10, 0, 0),
+ LocalDateTime.of(2026, 7, 8, 10, 5, 0)
+ );
+ }
+
+ private static CadFunctionResponse sampleFunction(Long id, String functionName) {
+ return new CadFunctionResponse(
+ id,
+ 1L,
+ functionName,
+ "mounting",
+ "固定外部零件",
+ "人工确认",
+ List.of(new CadFunctionStructureItemResponse(2000L, 100L, "加强筋", "rib", 0)),
+ LocalDateTime.of(2026, 7, 8, 10, 0, 0),
+ LocalDateTime.of(2026, 7, 8, 10, 5, 0)
+ );
+ }
+}
diff --git a/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java b/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java
new file mode 100644
index 0000000..97ba58d
--- /dev/null
+++ b/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java
@@ -0,0 +1,413 @@
+package com.cadlabel.service.impl;
+
+import com.cadlabel.common.exception.BizException;
+import com.cadlabel.dto.cad.annotation.CadFunctionResponse;
+import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse;
+import com.cadlabel.dto.cad.annotation.CadStructureResponse;
+import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.CreateCadStructureRequest;
+import com.cadlabel.dto.cad.annotation.SaveHistoryAnnotationRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadFunctionRequest;
+import com.cadlabel.dto.cad.annotation.UpdateCadStructureRequest;
+import com.cadlabel.service.CadAnnotationService;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest(properties = {
+ "spring.datasource.url=jdbc:h2:mem:cad-annotation-service;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
+ "spring.datasource.driver-class-name=org.h2.Driver",
+ "spring.datasource.username=sa",
+ "spring.datasource.password="
+})
+@ActiveProfiles("test")
+class CadAnnotationServiceIntegrationTest {
+
+ @Autowired
+ private CadAnnotationService cadAnnotationService;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @BeforeEach
+ void setUp() {
+ jdbcTemplate.execute("DROP TABLE IF EXISTS cad_function_structure_rel");
+ jdbcTemplate.execute("DROP TABLE IF EXISTS cad_function");
+ jdbcTemplate.execute("DROP TABLE IF EXISTS cad_structure_history_rel");
+ jdbcTemplate.execute("DROP TABLE IF EXISTS cad_structure");
+ jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history_annotation");
+ jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history");
+ jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model");
+
+ jdbcTemplate.execute("""
+ CREATE TABLE cad_model (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ category_id BIGINT NOT NULL,
+ model_name VARCHAR(255) NOT NULL DEFAULT '',
+ source_sw_filename VARCHAR(255) NOT NULL,
+ source_sw_path VARCHAR(1024) NOT NULL,
+ feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '',
+ view_path VARCHAR(1024) NOT NULL DEFAULT '',
+ application_scenario VARCHAR(255) NOT NULL DEFAULT '',
+ material VARCHAR(255) NOT NULL DEFAULT '',
+ manufacturing_method VARCHAR(255) NOT NULL DEFAULT '',
+ unit VARCHAR(64) NOT NULL DEFAULT '',
+ user_requirement TEXT,
+ design_description TEXT,
+ convert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
+ error_message TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """);
+ jdbcTemplate.execute("""
+ CREATE TABLE cad_model_history (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ model_id BIGINT NOT NULL,
+ source_feature_id VARCHAR(128) NOT NULL DEFAULT '',
+ history_index INT NOT NULL,
+ history_name VARCHAR(255) NOT NULL DEFAULT '',
+ operation_type VARCHAR(64) NOT NULL DEFAULT '',
+ step_path VARCHAR(1024) NOT NULL DEFAULT '',
+ glb_path VARCHAR(1024) NOT NULL DEFAULT '',
+ convert_status VARCHAR(32) NOT NULL DEFAULT 'pending',
+ error_message TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """);
+ jdbcTemplate.execute("""
+ CREATE TABLE cad_model_history_annotation (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ model_id BIGINT NOT NULL,
+ history_id BIGINT NOT NULL,
+ modeling_description TEXT,
+ supplement_description TEXT,
+ remark TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_history_annotation_history (history_id)
+ )
+ """);
+ jdbcTemplate.execute("""
+ CREATE TABLE cad_structure (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ model_id BIGINT NOT NULL,
+ structure_name VARCHAR(255) NOT NULL DEFAULT '',
+ structure_type VARCHAR(64) NOT NULL DEFAULT '',
+ purpose VARCHAR(1024) NOT NULL DEFAULT '',
+ solution VARCHAR(1024) NOT NULL DEFAULT '',
+ reason TEXT,
+ remark TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted TINYINT NOT NULL DEFAULT 0
+ )
+ """);
+ jdbcTemplate.execute("""
+ CREATE TABLE cad_structure_history_rel (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ model_id BIGINT NOT NULL,
+ structure_id BIGINT NOT NULL,
+ history_id BIGINT NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_structure_history (structure_id, history_id)
+ )
+ """);
+ jdbcTemplate.execute("""
+ CREATE TABLE cad_function (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ model_id BIGINT NOT NULL,
+ function_name VARCHAR(255) NOT NULL DEFAULT '',
+ function_type VARCHAR(64) NOT NULL DEFAULT '',
+ goal_description TEXT,
+ remark TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted TINYINT NOT NULL DEFAULT 0
+ )
+ """);
+ jdbcTemplate.execute("""
+ CREATE TABLE cad_function_structure_rel (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ model_id BIGINT NOT NULL,
+ function_id BIGINT NOT NULL,
+ structure_id BIGINT NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_function_structure (function_id, structure_id)
+ )
+ """);
+
+ insertModel(1L, "轴承座");
+ insertModel(2L, "连接板");
+ insertHistory(10L, 1L, "feat_001", 1, "凸台-拉伸1");
+ insertHistory(11L, 1L, "feat_002", 2, "倒角1");
+ insertHistory(20L, 2L, "feat_101", 1, "拉伸2");
+ }
+
+ @Test
+ void saveHistoryAnnotationInsertsThenUpdatesSameHistory() {
+ CadModelHistoryAnnotationResponse created = cadAnnotationService.saveHistoryAnnotation(1L, 10L,
+ new SaveHistoryAnnotationRequest("创建底座", "覆盖底面", "初次标注"));
+
+ CadModelHistoryAnnotationResponse updated = cadAnnotationService.saveHistoryAnnotation(1L, 10L,
+ new SaveHistoryAnnotationRequest("创建底座-修改", "覆盖底面-修改", "复核通过"));
+
+ assertThat(updated.id()).isEqualTo(created.id());
+ assertThat(updated.modelingDescription()).isEqualTo("创建底座-修改");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM cad_model_history_annotation WHERE history_id = 10",
+ Long.class
+ )).isEqualTo(1L);
+ }
+
+ @Test
+ void saveHistoryAnnotationRejectsHistoryOutsideModel() {
+ assertThatThrownBy(() -> cadAnnotationService.saveHistoryAnnotation(1L, 20L,
+ new SaveHistoryAnnotationRequest("错误", null, null)))
+ .isInstanceOf(BizException.class)
+ .hasMessage("建模历史不存在");
+ }
+
+ @Test
+ void createStructureWritesRelationsInRequestOrder() {
+ CadStructureResponse response = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
+ "加强筋",
+ "rib",
+ "提高强度",
+ "多段拉伸",
+ "受力集中",
+ "人工确认",
+ List.of(11L, 10L)
+ ));
+
+ assertThat(response.id()).isPositive();
+ assertThat(response.historyItems()).extracting(item -> item.historyId()).containsExactly(11L, 10L);
+ assertThat(response.historyItems()).extracting(item -> item.sortOrder()).containsExactly(0, 1);
+ assertThat(jdbcTemplate.queryForList(
+ "SELECT history_id FROM cad_structure_history_rel WHERE structure_id = ? ORDER BY sort_order",
+ Long.class,
+ response.id()
+ )).containsExactly(11L, 10L);
+ }
+
+ @Test
+ void createStructureRejectsDuplicateOrInvalidHistoryIds() {
+ assertThatThrownBy(() -> cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
+ "加强筋",
+ "rib",
+ null,
+ null,
+ null,
+ null,
+ List.of(10L, 10L)
+ )))
+ .isInstanceOf(BizException.class)
+ .hasMessage("historyIds 不能包含重复值");
+
+ assertThatThrownBy(() -> cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
+ "加强筋",
+ "rib",
+ null,
+ null,
+ null,
+ null,
+ List.of(20L)
+ )))
+ .isInstanceOf(BizException.class)
+ .hasMessage("建模历史不存在");
+ }
+
+ @Test
+ void updateStructurePartiallyAndReplacesRelations() {
+ CadStructureResponse created = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest(
+ "加强筋",
+ "rib",
+ "提高强度",
+ "多段拉伸",
+ null,
+ null,
+ List.of(10L, 11L)
+ ));
+
+ CadStructureResponse updated = cadAnnotationService.updateStructure(1L, created.id(), new UpdateCadStructureRequest(
+ "加强筋-修改",
+ null,
+ null,
+ null,
+ null,
+ "已复核",
+ List.of(11L)
+ ));
+
+ assertThat(updated.structureName()).isEqualTo("加强筋-修改");
+ assertThat(updated.structureType()).isEqualTo("rib");
+ assertThat(updated.remark()).isEqualTo("已复核");
+ assertThat(updated.historyItems()).extracting(item -> item.historyId()).containsExactly(11L);
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM cad_structure_history_rel WHERE structure_id = ?",
+ Long.class,
+ created.id()
+ )).isEqualTo(1L);
+ }
+
+ @Test
+ void 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 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 structures = cadAnnotationService.listStructures(1L);
+ List 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"
+ );
+ }
+}