标注接口

This commit is contained in:
lxp
2026-07-08 16:01:51 +08:00
parent 3f30d574d6
commit bc18930b7a
28 changed files with 2040 additions and 1 deletions
@@ -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;
/**
* 保存建模历史步骤标注。
*
* <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>创建时必须选择至少一个结构,关系排序按 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));
}
}
@@ -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,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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,37 @@
package com.cadlabel.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("cad_model_history_annotation")
public class CadModelHistoryAnnotation {
/** 步骤标注ID。 */
@TableId(type = IdType.AUTO)
private Long id;
/** 关联 cad_model.id。 */
private Long modelId;
/** 关联 cad_model_history.id。 */
private Long historyId;
/** 建模说明:这个 CAD 操作自身做了什么。 */
private String modelingDescription;
/** 补充描述:几何范围、解析异常、与前后步骤关系。 */
private String supplementDescription;
/** 备注:人工复核说明、异常说明。 */
private String remark;
/** 创建时间。 */
private LocalDateTime createdAt;
/** 更新时间。 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,46 @@
package com.cadlabel.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("cad_structure")
public class CadStructure {
/** 结构ID。 */
@TableId(type = IdType.AUTO)
private Long id;
/** 关联 cad_model.id。 */
private Long modelId;
/** 结构名称。 */
private String structureName;
/** 结构类型。 */
private String structureType;
/** 目的。 */
private String purpose;
/** 方案。 */
private String solution;
/** 原因。 */
private String reason;
/** 备注。 */
private String remark;
/** 创建时间。 */
private LocalDateTime createdAt;
/** 更新时间。 */
private LocalDateTime updatedAt;
/** 逻辑删除标记:0 未删除,1 已删除。 */
private Integer deleted;
}
@@ -0,0 +1,34 @@
package com.cadlabel.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
@Data
@TableName("cad_structure_history_rel")
public class CadStructureHistoryRel {
/** 结构-步骤关系ID。 */
@TableId(type = IdType.AUTO)
private Long id;
/** 关联 cad_model.id。 */
private Long modelId;
/** 关联 cad_structure.id。 */
private Long structureId;
/** 关联 cad_model_history.id。 */
private Long historyId;
/** 结构内步骤排序。 */
private Integer sortOrder;
/** 创建时间。 */
private LocalDateTime createdAt;
/** 更新时间。 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.cadlabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cadlabel.entity.CadFunction;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CadFunctionMapper extends BaseMapper<CadFunction> {
}
@@ -0,0 +1,9 @@
package com.cadlabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cadlabel.entity.CadFunctionStructureRel;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CadFunctionStructureRelMapper extends BaseMapper<CadFunctionStructureRel> {
}
@@ -0,0 +1,9 @@
package com.cadlabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cadlabel.entity.CadModelHistoryAnnotation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CadModelHistoryAnnotationMapper extends BaseMapper<CadModelHistoryAnnotation> {
}
@@ -0,0 +1,9 @@
package com.cadlabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cadlabel.entity.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,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 {
/**
* 保存建模历史步骤标注。
*
* <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);
/**
* 创建功能并建立功能与结构的关系。
*/
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);
}
@@ -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 标注编排服务。
*
* <p>负责建模历史标注、结构、功能以及两层关系维护。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CadAnnotationServiceImpl implements CadAnnotationService {
private final CadModelMapper cadModelMapper;
private final CadModelHistoryMapper cadModelHistoryMapper;
private final CadModelHistoryAnnotationMapper annotationMapper;
private final CadStructureMapper structureMapper;
private final CadStructureHistoryRelMapper structureHistoryRelMapper;
private final CadFunctionMapper functionMapper;
private final CadFunctionStructureRelMapper functionStructureRelMapper;
/**
* 保存建模历史步骤标注。
*
* <p>先校验模型和历史步骤归属关系,再按 historyId 做插入或更新,保证前端重复保存是幂等的。</p>
*/
@Override
@Transactional
public CadModelHistoryAnnotationResponse saveHistoryAnnotation(
Long modelId,
Long historyId,
SaveHistoryAnnotationRequest request
) {
requireModel(modelId);
requireHistory(modelId, historyId);
SaveHistoryAnnotationRequest safeRequest = request == null
? new SaveHistoryAnnotationRequest(null, null, null)
: request;
CadModelHistoryAnnotation existing = annotationMapper.selectOne(Wrappers.lambdaQuery(CadModelHistoryAnnotation.class)
.eq(CadModelHistoryAnnotation::getHistoryId, historyId));
if (existing == null) {
CadModelHistoryAnnotation annotation = new CadModelHistoryAnnotation();
annotation.setModelId(modelId);
annotation.setHistoryId(historyId);
annotation.setModelingDescription(safeRequest.modelingDescription());
annotation.setSupplementDescription(safeRequest.supplementDescription());
annotation.setRemark(safeRequest.remark());
log.info("Creating CAD history annotation. modelId={}, historyId={}", modelId, historyId);
annotationMapper.insert(annotation);
return toAnnotationResponse(annotationMapper.selectById(annotation.getId()));
}
LocalDateTime now = LocalDateTime.now();
log.info("Updating CAD history annotation. modelId={}, historyId={}, annotationId={}",
modelId, historyId, existing.getId());
annotationMapper.update(null, Wrappers.lambdaUpdate(CadModelHistoryAnnotation.class)
.eq(CadModelHistoryAnnotation::getId, existing.getId())
.set(CadModelHistoryAnnotation::getModelId, modelId)
.set(CadModelHistoryAnnotation::getModelingDescription, safeRequest.modelingDescription())
.set(CadModelHistoryAnnotation::getSupplementDescription, safeRequest.supplementDescription())
.set(CadModelHistoryAnnotation::getRemark, safeRequest.remark())
.set(CadModelHistoryAnnotation::getUpdatedAt, now));
return toAnnotationResponse(annotationMapper.selectById(existing.getId()));
}
/**
* 创建结构并绑定建模历史步骤。
*
* <p>结构基础记录和结构-历史关系在同一事务内写入,任一环节失败都会回滚。</p>
*/
@Override
@Transactional
public CadStructureResponse createStructure(Long modelId, CreateCadStructureRequest request) {
requireModel(modelId);
if (request == null) {
throw invalidRequest("请求体不能为空");
}
validateStructureName(request.structureName());
validateMaxLength("structureType", request.structureType(), 64);
validateMaxLength("purpose", request.purpose(), 1024);
validateMaxLength("solution", request.solution(), 1024);
List<Long> historyIds = validateIdList("historyIds", request.historyIds());
requireHistories(modelId, historyIds);
CadStructure structure = new CadStructure();
structure.setModelId(modelId);
structure.setStructureName(request.structureName());
structure.setStructureType(defaultText(request.structureType()));
structure.setPurpose(defaultText(request.purpose()));
structure.setSolution(defaultText(request.solution()));
structure.setReason(request.reason());
structure.setRemark(request.remark());
structure.setDeleted(0);
log.info("Creating CAD structure. modelId={}, historyCount={}", modelId, historyIds.size());
structureMapper.insert(structure);
replaceStructureHistoryRelations(modelId, structure.getId(), historyIds);
return getStructure(modelId, structure.getId());
}
/**
* 修改结构基础信息和历史步骤关系。
*
* <p>请求未传的普通字段保持原值;传入 historyIds 时会按请求数组全量替换关系。</p>
*/
@Override
@Transactional
public CadStructureResponse updateStructure(Long modelId, Long structureId, UpdateCadStructureRequest request) {
CadStructure structure = requireStructure(modelId, structureId);
if (request == null) {
return toStructureResponse(structure);
}
List<String> updatedFields = structureUpdatedFields(request);
validateStructureUpdateRequest(request);
if (request.historyIds() != null) {
List<Long> historyIds = validateIdList("historyIds", request.historyIds());
requireHistories(modelId, historyIds);
replaceStructureHistoryRelations(modelId, structureId, historyIds);
updatedFields.add("historyIds");
}
if (!updatedFields.isEmpty()) {
applyStructureUpdate(structure, request);
structure.setUpdatedAt(LocalDateTime.now());
log.info("Updating CAD structure. modelId={}, structureId={}, fields={}",
modelId, structureId, updatedFields);
structureMapper.updateById(structure);
}
return getStructure(modelId, structureId);
}
/**
* 查询模型下的结构列表。
*
* <p>只查询 deleted=0 的结构,并为每个结构组装已关联的建模历史步骤。</p>
*/
@Override
public List<CadStructureResponse> listStructures(Long modelId) {
requireModel(modelId);
return structureMapper.selectList(Wrappers.lambdaQuery(CadStructure.class)
.eq(CadStructure::getModelId, modelId)
.eq(CadStructure::getDeleted, 0)
.orderByAsc(CadStructure::getId))
.stream()
.map(this::toStructureResponse)
.toList();
}
/**
* 查询结构详情。
*
* <p>详情查询会校验结构归属当前模型,并排除已逻辑删除的结构。</p>
*/
@Override
public CadStructureResponse getStructure(Long modelId, Long structureId) {
return toStructureResponse(requireStructure(modelId, structureId));
}
/**
* 创建功能并绑定结构。
*
* <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));
}
/**
* 校验模型存在并返回模型记录。
*/
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);
}
}
+1 -1
View File
@@ -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:
@@ -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)
);
}
}
@@ -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<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"
);
}
}