From 3f30d574d6d5a4dd4d16c5f31d10be6c45c662bd Mon Sep 17 00:00:00 2001 From: lxp <2770281812@qq.com> Date: Wed, 8 Jul 2026 15:06:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=92=8C?= =?UTF-8?q?=E5=BB=BA=E6=A8=A1=E5=8E=86=E5=8F=B2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 9 + .../com/cadlabel/CadLabelApplication.java | 2 - .../controller/CadModelController.java | 63 ++++ .../dto/cad/CadModelHistoryResponse.java | 19 + .../cadlabel/dto/cad/CadModelListQuery.java | 10 + .../cadlabel/dto/cad/CadModelResponse.java | 24 ++ .../dto/cad/UpdateCadModelRequest.java | 22 ++ .../java/com/cadlabel/entity/CadModel.java | 31 ++ .../com/cadlabel/entity/CadModelHistory.java | 26 ++ .../mapper/CadModelHistoryMapper.java | 9 + .../com/cadlabel/mapper/CadModelMapper.java | 9 + .../com/cadlabel/service/CadModelService.java | 19 + .../service/impl/CadModelServiceImpl.java | 334 ++++++++++++++++++ src/main/resources/application-dev.yml | 2 +- .../controller/CadModelControllerTest.java | 173 +++++++++ .../impl/CadModelServiceIntegrationTest.java | 275 ++++++++++++++ 16 files changed, 1024 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/main/java/com/cadlabel/controller/CadModelController.java create mode 100644 src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java create mode 100644 src/main/java/com/cadlabel/dto/cad/CadModelListQuery.java create mode 100644 src/main/java/com/cadlabel/dto/cad/CadModelResponse.java create mode 100644 src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java create mode 100644 src/main/java/com/cadlabel/entity/CadModel.java create mode 100644 src/main/java/com/cadlabel/entity/CadModelHistory.java create mode 100644 src/main/java/com/cadlabel/mapper/CadModelHistoryMapper.java create mode 100644 src/main/java/com/cadlabel/mapper/CadModelMapper.java create mode 100644 src/main/java/com/cadlabel/service/CadModelService.java create mode 100644 src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java create mode 100644 src/test/java/com/cadlabel/controller/CadModelControllerTest.java create mode 100644 src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38aaa34 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +## 代码实现原则 +- 优先保证业务正确性、可维护性、可读性,再考虑炫技。 +- 修改代码时忽略.sql文件,如果遇到需要改表结构,直接发我sql。 +## 日志规范 +- 关键业务节点必须补充必要日志,方便线上排查。 +- 不要打印密码、Token、手机号、身份证、支付密钥等敏感信息。 +- 异常日志必须包含足够定位问题的信息。 +## 回复规范 +- 永远用中文回复。 \ No newline at end of file diff --git a/src/main/java/com/cadlabel/CadLabelApplication.java b/src/main/java/com/cadlabel/CadLabelApplication.java index 9a8f232..aa2f236 100644 --- a/src/main/java/com/cadlabel/CadLabelApplication.java +++ b/src/main/java/com/cadlabel/CadLabelApplication.java @@ -1,11 +1,9 @@ package com.cadlabel; -import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -@MapperScan("com.cadlabel.mapper") @ConfigurationPropertiesScan @SpringBootApplication public class CadLabelApplication { diff --git a/src/main/java/com/cadlabel/controller/CadModelController.java b/src/main/java/com/cadlabel/controller/CadModelController.java new file mode 100644 index 0000000..63e7643 --- /dev/null +++ b/src/main/java/com/cadlabel/controller/CadModelController.java @@ -0,0 +1,63 @@ +package com.cadlabel.controller; + +import com.cadlabel.common.api.ApiResponse; +import com.cadlabel.common.api.PageResponse; +import com.cadlabel.dto.cad.CadModelHistoryResponse; +import com.cadlabel.dto.cad.CadModelListQuery; +import com.cadlabel.dto.cad.CadModelResponse; +import com.cadlabel.dto.cad.UpdateCadModelRequest; +import com.cadlabel.service.CadModelService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/cad-models") +@RequiredArgsConstructor +public class CadModelController { + + private final CadModelService cadModelService; + + @GetMapping + public ApiResponse> listModels( + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int size, + @RequestParam(required = false) @Positive Long categoryId, + @RequestParam(required = false) String convertStatus, + @RequestParam(required = false) String keyword + ) { + return ApiResponse.success(cadModelService.listModels( + new CadModelListQuery(page, size, categoryId, convertStatus, keyword) + )); + } + + @GetMapping("/{id}") + public ApiResponse getModel(@PathVariable @Positive Long id) { + return ApiResponse.success(cadModelService.getModel(id)); + } + + @PostMapping("/{id}") + public ApiResponse updateModel( + @PathVariable @Positive Long id, + @Valid @RequestBody(required = false) UpdateCadModelRequest request + ) { + return ApiResponse.success(cadModelService.updateModel(id, request)); + } + + @GetMapping("/{modelId}/histories") + public ApiResponse> listHistories(@PathVariable @Positive Long modelId) { + return ApiResponse.success(cadModelService.listHistories(modelId)); + } +} diff --git a/src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java b/src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java new file mode 100644 index 0000000..e8fee3f --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java @@ -0,0 +1,19 @@ +package com.cadlabel.dto.cad; + +import java.time.LocalDateTime; + +public record CadModelHistoryResponse( + Long id, + Long modelId, + String sourceFeatureId, + Integer historyIndex, + String historyName, + String operationType, + String stepPath, + String glbPath, + String convertStatus, + String errorMessage, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/cadlabel/dto/cad/CadModelListQuery.java b/src/main/java/com/cadlabel/dto/cad/CadModelListQuery.java new file mode 100644 index 0000000..d85f38d --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cad/CadModelListQuery.java @@ -0,0 +1,10 @@ +package com.cadlabel.dto.cad; + +public record CadModelListQuery( + int page, + int size, + Long categoryId, + String convertStatus, + String keyword +) { +} diff --git a/src/main/java/com/cadlabel/dto/cad/CadModelResponse.java b/src/main/java/com/cadlabel/dto/cad/CadModelResponse.java new file mode 100644 index 0000000..949b059 --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cad/CadModelResponse.java @@ -0,0 +1,24 @@ +package com.cadlabel.dto.cad; + +import java.time.LocalDateTime; + +public record CadModelResponse( + Long id, + Long categoryId, + String modelName, + String sourceSwFilename, + String sourceSwPath, + String featureTreeJsonPath, + String viewPath, + String applicationScenario, + String material, + String manufacturingMethod, + String unit, + String userRequirement, + String designDescription, + String convertStatus, + String errorMessage, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java b/src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java new file mode 100644 index 0000000..7069e87 --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java @@ -0,0 +1,22 @@ +package com.cadlabel.dto.cad; + +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record UpdateCadModelRequest( + @Positive Long categoryId, + @Size(max = 255) String modelName, + @Size(max = 255) String sourceSwFilename, + @Size(max = 1024) String sourceSwPath, + @Size(max = 1024) String featureTreeJsonPath, + @Size(max = 1024) String viewPath, + @Size(max = 255) String applicationScenario, + @Size(max = 255) String material, + @Size(max = 255) String manufacturingMethod, + @Size(max = 64) String unit, + String userRequirement, + String designDescription, + @Size(max = 32) String convertStatus, + String errorMessage +) { +} diff --git a/src/main/java/com/cadlabel/entity/CadModel.java b/src/main/java/com/cadlabel/entity/CadModel.java new file mode 100644 index 0000000..0cc3925 --- /dev/null +++ b/src/main/java/com/cadlabel/entity/CadModel.java @@ -0,0 +1,31 @@ +package com.cadlabel.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("cad_model") +public class CadModel { + + @TableId(type = IdType.AUTO) + private Long id; + private Long categoryId; + private String modelName; + private String sourceSwFilename; + private String sourceSwPath; + 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 new file mode 100644 index 0000000..d342b19 --- /dev/null +++ b/src/main/java/com/cadlabel/entity/CadModelHistory.java @@ -0,0 +1,26 @@ +package com.cadlabel.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("cad_model_history") +public class CadModelHistory { + + @TableId(type = IdType.AUTO) + private Long id; + private Long modelId; + private String sourceFeatureId; + private Integer historyIndex; + private String historyName; + private String operationType; + private String stepPath; + private String glbPath; + private String convertStatus; + private String errorMessage; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/cadlabel/mapper/CadModelHistoryMapper.java b/src/main/java/com/cadlabel/mapper/CadModelHistoryMapper.java new file mode 100644 index 0000000..5e7c052 --- /dev/null +++ b/src/main/java/com/cadlabel/mapper/CadModelHistoryMapper.java @@ -0,0 +1,9 @@ +package com.cadlabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.cadlabel.entity.CadModelHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CadModelHistoryMapper extends BaseMapper { +} diff --git a/src/main/java/com/cadlabel/mapper/CadModelMapper.java b/src/main/java/com/cadlabel/mapper/CadModelMapper.java new file mode 100644 index 0000000..1a1a09c --- /dev/null +++ b/src/main/java/com/cadlabel/mapper/CadModelMapper.java @@ -0,0 +1,9 @@ +package com.cadlabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.cadlabel.entity.CadModel; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CadModelMapper extends BaseMapper { +} diff --git a/src/main/java/com/cadlabel/service/CadModelService.java b/src/main/java/com/cadlabel/service/CadModelService.java new file mode 100644 index 0000000..e3b726d --- /dev/null +++ b/src/main/java/com/cadlabel/service/CadModelService.java @@ -0,0 +1,19 @@ +package com.cadlabel.service; + +import com.cadlabel.common.api.PageResponse; +import com.cadlabel.dto.cad.CadModelHistoryResponse; +import com.cadlabel.dto.cad.CadModelListQuery; +import com.cadlabel.dto.cad.CadModelResponse; +import com.cadlabel.dto.cad.UpdateCadModelRequest; +import java.util.List; + +public interface CadModelService { + + PageResponse listModels(CadModelListQuery query); + + CadModelResponse getModel(Long id); + + CadModelResponse updateModel(Long id, UpdateCadModelRequest request); + + List listHistories(Long modelId); +} diff --git a/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java b/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java new file mode 100644 index 0000000..37b3c17 --- /dev/null +++ b/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java @@ -0,0 +1,334 @@ +package com.cadlabel.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.cadlabel.common.api.ApiCode; +import com.cadlabel.common.api.PageResponse; +import com.cadlabel.common.exception.BizException; +import com.cadlabel.dto.cad.CadModelHistoryResponse; +import com.cadlabel.dto.cad.CadModelListQuery; +import com.cadlabel.dto.cad.CadModelResponse; +import com.cadlabel.dto.cad.UpdateCadModelRequest; +import com.cadlabel.entity.CadModel; +import com.cadlabel.entity.CadModelHistory; +import com.cadlabel.mapper.CadModelHistoryMapper; +import com.cadlabel.mapper.CadModelMapper; +import com.cadlabel.service.CadModelService; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * CAD 模型服务实现。 + * + *

负责模型列表、详情、基础信息修改和建模历史查询。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CadModelServiceImpl implements CadModelService { + + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_SIZE = 10; + private static final int MAX_SIZE = 100; + private static final Set MODEL_CONVERT_STATUSES = Set.of("pending", "success", "failed", "partial_failed"); + + private final CadModelMapper cadModelMapper; + private final CadModelHistoryMapper cadModelHistoryMapper; + + /** + * 分页查询 CAD 模型列表。 + */ + @Override + public PageResponse listModels(CadModelListQuery query) { + int page = resolvePage(query == null ? DEFAULT_PAGE : query.page()); + int size = resolveSize(query == null ? DEFAULT_SIZE : query.size()); + Long categoryId = query == null ? null : query.categoryId(); + String convertStatus = normalizeOptionalText(query == null ? null : query.convertStatus()); + String keyword = normalizeOptionalText(query == null ? null : query.keyword()); + + if (categoryId != null && categoryId <= 0) { + throw invalidRequest("categoryId 必须为正数"); + } + if (StringUtils.hasText(convertStatus)) { + validateModelConvertStatus(convertStatus); + } + + Page result = cadModelMapper.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + .eq(categoryId != null, CadModel::getCategoryId, categoryId) + .eq(StringUtils.hasText(convertStatus), CadModel::getConvertStatus, convertStatus) + .and(StringUtils.hasText(keyword), nested -> nested + .like(CadModel::getModelName, keyword) + .or() + .like(CadModel::getSourceSwFilename, keyword)) + .orderByDesc(CadModel::getCreatedAt) + .orderByDesc(CadModel::getId)); + + List items = result.getRecords().stream().map(this::toModelResponse).toList(); + return new PageResponse<>(items, result.getCurrent(), result.getSize(), result.getTotal()); + } + + /** + * 获取 CAD 模型详情。 + */ + @Override + public CadModelResponse getModel(Long id) { + return toModelResponse(requireModel(id)); + } + + /** + * 修改 CAD 模型基础信息。 + */ + @Override + @Transactional + public CadModelResponse updateModel(Long id, UpdateCadModelRequest request) { + CadModel model = requireModel(id); + List updatedFields = updatedFields(request); + if (updatedFields.isEmpty()) { + return toModelResponse(model); + } + + validateUpdateRequest(request); + ensureCategoryFilenameUnique(id, model, request); + applyUpdate(model, request); + model.setUpdatedAt(LocalDateTime.now()); + + log.info("Updating CAD model. id={}, fields={}", id, updatedFields); + try { + cadModelMapper.updateById(model); + } catch (DuplicateKeyException exception) { + throw duplicateModelException(); + } + return getModel(id); + } + + /** + * 查询模型完整建模历史。 + */ + @Override + public List listHistories(Long modelId) { + requireModel(modelId); + return cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class) + .eq(CadModelHistory::getModelId, modelId) + .orderByAsc(CadModelHistory::getHistoryIndex) + .orderByAsc(CadModelHistory::getId)) + .stream() + .map(this::toHistoryResponse) + .toList(); + } + + private CadModel requireModel(Long id) { + validatePositiveId(id); + CadModel model = cadModelMapper.selectById(id); + if (model == null) { + throw modelNotFound(); + } + return model; + } + + private void validatePositiveId(Long id) { + if (id == null || id <= 0) { + throw invalidRequest("id 必须为正数"); + } + } + + private int resolvePage(int page) { + if (page < 1) { + throw invalidRequest("page 必须大于等于 1"); + } + return page; + } + + private int resolveSize(int size) { + if (size < 1 || size > MAX_SIZE) { + throw invalidRequest("size 必须在 1 到 100 之间"); + } + return size; + } + + private void ensureCategoryFilenameUnique(Long id, CadModel current, UpdateCadModelRequest request) { + if (request.categoryId() == null && request.sourceSwFilename() == null) { + return; + } + + Long categoryId = request.categoryId() == null ? current.getCategoryId() : request.categoryId(); + String sourceSwFilename = request.sourceSwFilename() == null + ? current.getSourceSwFilename() + : request.sourceSwFilename(); + Long count = cadModelMapper.selectCount(Wrappers.lambdaQuery(CadModel.class) + .eq(CadModel::getCategoryId, categoryId) + .eq(CadModel::getSourceSwFilename, sourceSwFilename) + .ne(CadModel::getId, id)); + if (count != null && count > 0) { + throw duplicateModelException(); + } + } + + private void validateUpdateRequest(UpdateCadModelRequest request) { + if (request.categoryId() != null && request.categoryId() <= 0) { + throw invalidRequest("categoryId 必须为正数"); + } + validateMaxLength("modelName", request.modelName(), 255); + validateMaxLength("sourceSwFilename", request.sourceSwFilename(), 255); + validateMaxLength("sourceSwPath", request.sourceSwPath(), 1024); + validateMaxLength("featureTreeJsonPath", request.featureTreeJsonPath(), 1024); + validateMaxLength("viewPath", request.viewPath(), 1024); + validateMaxLength("applicationScenario", request.applicationScenario(), 255); + validateMaxLength("material", request.material(), 255); + validateMaxLength("manufacturingMethod", request.manufacturingMethod(), 255); + validateMaxLength("unit", request.unit(), 64); + validateMaxLength("convertStatus", request.convertStatus(), 32); + if (request.convertStatus() != null) { + validateModelConvertStatus(request.convertStatus()); + } + } + + private void validateMaxLength(String field, String value, int maxLength) { + if (value != null && value.length() > maxLength) { + throw invalidRequest(field + " 长度不能超过 " + maxLength); + } + } + + private void applyUpdate(CadModel model, UpdateCadModelRequest request) { + if (request.categoryId() != null) { + model.setCategoryId(request.categoryId()); + } + if (request.modelName() != null) { + model.setModelName(request.modelName()); + } + if (request.sourceSwFilename() != null) { + model.setSourceSwFilename(request.sourceSwFilename()); + } + if (request.sourceSwPath() != null) { + model.setSourceSwPath(request.sourceSwPath()); + } + if (request.featureTreeJsonPath() != null) { + model.setFeatureTreeJsonPath(request.featureTreeJsonPath()); + } + if (request.viewPath() != null) { + model.setViewPath(request.viewPath()); + } + if (request.applicationScenario() != null) { + model.setApplicationScenario(request.applicationScenario()); + } + if (request.material() != null) { + model.setMaterial(request.material()); + } + if (request.manufacturingMethod() != null) { + model.setManufacturingMethod(request.manufacturingMethod()); + } + if (request.unit() != null) { + model.setUnit(request.unit()); + } + if (request.userRequirement() != null) { + model.setUserRequirement(request.userRequirement()); + } + if (request.designDescription() != null) { + model.setDesignDescription(request.designDescription()); + } + if (request.convertStatus() != null) { + model.setConvertStatus(request.convertStatus()); + } + if (request.errorMessage() != null) { + model.setErrorMessage(request.errorMessage()); + } + } + + private List updatedFields(UpdateCadModelRequest request) { + List fields = new ArrayList<>(); + if (request == null) { + return fields; + } + addField(fields, "categoryId", request.categoryId()); + addField(fields, "modelName", request.modelName()); + addField(fields, "sourceSwFilename", request.sourceSwFilename()); + addField(fields, "sourceSwPath", request.sourceSwPath()); + addField(fields, "featureTreeJsonPath", request.featureTreeJsonPath()); + addField(fields, "viewPath", request.viewPath()); + addField(fields, "applicationScenario", request.applicationScenario()); + addField(fields, "material", request.material()); + addField(fields, "manufacturingMethod", request.manufacturingMethod()); + addField(fields, "unit", request.unit()); + addField(fields, "userRequirement", request.userRequirement()); + addField(fields, "designDescription", request.designDescription()); + addField(fields, "convertStatus", request.convertStatus()); + addField(fields, "errorMessage", request.errorMessage()); + return fields; + } + + private void addField(List fields, String fieldName, Object value) { + if (value != null) { + fields.add(fieldName); + } + } + + private String normalizeOptionalText(String value) { + return StringUtils.hasText(value) ? value.trim() : ""; + } + + private void validateModelConvertStatus(String status) { + if (!MODEL_CONVERT_STATUSES.contains(status)) { + throw invalidRequest("convertStatus 取值非法"); + } + } + + private BizException invalidRequest(String message) { + return new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), ApiCode.INVALID_REQUEST, message); + } + + private BizException modelNotFound() { + return new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "CAD 模型不存在"); + } + + private BizException duplicateModelException() { + return new BizException(HttpStatus.CONFLICT.value(), "CAD_MODEL_DUPLICATE", "同分类下源 SolidWorks 文件名已存在"); + } + + private CadModelResponse toModelResponse(CadModel model) { + return new CadModelResponse( + model.getId(), + model.getCategoryId(), + model.getModelName(), + model.getSourceSwFilename(), + model.getSourceSwPath(), + model.getFeatureTreeJsonPath(), + model.getViewPath(), + model.getApplicationScenario(), + model.getMaterial(), + model.getManufacturingMethod(), + model.getUnit(), + model.getUserRequirement(), + model.getDesignDescription(), + model.getConvertStatus(), + model.getErrorMessage(), + model.getCreatedAt(), + model.getUpdatedAt() + ); + } + + private CadModelHistoryResponse toHistoryResponse(CadModelHistory history) { + return new CadModelHistoryResponse( + history.getId(), + history.getModelId(), + history.getSourceFeatureId(), + history.getHistoryIndex(), + history.getHistoryName(), + history.getOperationType(), + history.getStepPath(), + history.getGlbPath(), + history.getConvertStatus(), + history.getErrorMessage(), + history.getCreatedAt(), + history.getUpdatedAt() + ); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2de1668..a258a59 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -3,7 +3,7 @@ spring: activate: on-profile: dev datasource: - url: ${APP_DB_URL:jdbc:mysql://49.232.4.148:3306/cadlabel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true} + url: ${APP_DB_URL:jdbc:mysql://39.105.93.138:3306/cadlabel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true} username: ${APP_DB_USERNAME:root} password: ${APP_DB_PASSWORD:mysql_QRWm4M} driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/src/test/java/com/cadlabel/controller/CadModelControllerTest.java b/src/test/java/com/cadlabel/controller/CadModelControllerTest.java new file mode 100644 index 0000000..44f8ee2 --- /dev/null +++ b/src/test/java/com/cadlabel/controller/CadModelControllerTest.java @@ -0,0 +1,173 @@ +package com.cadlabel.controller; + +import com.cadlabel.common.api.PageResponse; +import com.cadlabel.common.exception.BizException; +import com.cadlabel.common.exception.GlobalExceptionHandler; +import com.cadlabel.common.web.RequestIdFilter; +import com.cadlabel.config.JacksonConfig; +import com.cadlabel.dto.cad.CadModelHistoryResponse; +import com.cadlabel.dto.cad.CadModelListQuery; +import com.cadlabel.dto.cad.CadModelResponse; +import com.cadlabel.dto.cad.UpdateCadModelRequest; +import com.cadlabel.service.CadModelService; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CadModelController.class) +@Import({GlobalExceptionHandler.class, RequestIdFilter.class, JacksonConfig.class}) +class CadModelControllerTest { + + @MockitoBean + private CadModelService cadModelService; + + @Autowired + private MockMvc mockMvc; + + @Test + void listModelsReturnsPagedUnifiedResponse() throws Exception { + CadModelResponse model = sampleModel(1L, "轴承座"); + given(cadModelService.listModels(any(CadModelListQuery.class))).willReturn( + new PageResponse<>(List.of(model), 2, 5, 11) + ); + + mockMvc.perform(get("/api/cad-models") + .param("page", "2") + .param("size", "5") + .param("categoryId", "7") + .param("convertStatus", "success") + .param("keyword", "轴承")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.page").value(2)) + .andExpect(jsonPath("$.data.size").value(5)) + .andExpect(jsonPath("$.data.total").value(11)) + .andExpect(jsonPath("$.data.items[0].id").value(1)) + .andExpect(jsonPath("$.data.items[0].modelName").value("轴承座")); + + verify(cadModelService).listModels(new CadModelListQuery(2, 5, 7L, "success", "轴承")); + } + + @Test + void getModelReturnsUnifiedResponse() throws Exception { + given(cadModelService.getModel(1L)).willReturn(sampleModel(1L, "连接板")); + + mockMvc.perform(get("/api/cad-models/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.modelName").value("连接板")) + .andExpect(jsonPath("$.data.createdAt").value("2026-07-08 10:00:00")); + } + + @Test + void getModelReturnsNotFoundWhenMissing() throws Exception { + given(cadModelService.getModel(404L)).willThrow( + new BizException(HttpStatus.NOT_FOUND.value(), "NOT_FOUND", "CAD 模型不存在") + ); + + mockMvc.perform(get("/api/cad-models/404")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")) + .andExpect(jsonPath("$.message").value("CAD 模型不存在")); + } + + @Test + void updateModelAcceptsPartialJsonAndReturnsUpdatedModel() throws Exception { + given(cadModelService.updateModel(eq(1L), any())).willReturn(sampleModel(1L, "新名称")); + + mockMvc.perform(post("/api/cad-models/1") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "modelName": "新名称", + "userRequirement": null + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.modelName").value("新名称")); + + verify(cadModelService).updateModel(1L, new UpdateCadModelRequest( + null, + "新名称", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + )); + } + + @Test + void listHistoriesReturnsOrderedUnifiedResponse() throws Exception { + given(cadModelService.listHistories(1L)).willReturn(List.of( + new CadModelHistoryResponse( + 10L, + 1L, + "feat_001", + 1, + "凸台-拉伸1", + "extrude_add", + "steps/1.step", + "glbs/1.glb", + "success", + null, + LocalDateTime.of(2026, 7, 8, 10, 0, 0), + LocalDateTime.of(2026, 7, 8, 10, 5, 0) + ) + )); + + mockMvc.perform(get("/api/cad-models/1/histories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data[0].id").value(10)) + .andExpect(jsonPath("$.data[0].historyIndex").value(1)) + .andExpect(jsonPath("$.data[0].historyName").value("凸台-拉伸1")); + } + + private static CadModelResponse sampleModel(Long id, String modelName) { + return new CadModelResponse( + id, + 7L, + modelName, + "015133.sldprt", + "sw/015133.sldprt", + "features/015133.json", + "views/015133", + "工业夹具", + "铝合金", + "CNC", + "mm", + "轻量化", + "整体设计说明", + "success", + null, + LocalDateTime.of(2026, 7, 8, 10, 0, 0), + LocalDateTime.of(2026, 7, 8, 10, 5, 0) + ); + } +} diff --git a/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java b/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java new file mode 100644 index 0000000..1f735b8 --- /dev/null +++ b/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java @@ -0,0 +1,275 @@ +package com.cadlabel.service.impl; + +import com.cadlabel.common.api.PageResponse; +import com.cadlabel.common.exception.BizException; +import com.cadlabel.dto.cad.CadModelHistoryResponse; +import com.cadlabel.dto.cad.CadModelListQuery; +import com.cadlabel.dto.cad.CadModelResponse; +import com.cadlabel.dto.cad.UpdateCadModelRequest; +import com.cadlabel.service.CadModelService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:cad-model-service;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=" +}) +@ActiveProfiles("test") +class CadModelServiceIntegrationTest { + + @Autowired + private CadModelService cadModelService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history"); + jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model"); + jdbcTemplate.execute(""" + CREATE TABLE cad_model ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + category_id BIGINT NOT NULL, + model_name VARCHAR(255) NOT NULL DEFAULT '', + source_sw_filename VARCHAR(255) NOT NULL, + source_sw_path VARCHAR(1024) NOT NULL, + feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '', + view_path VARCHAR(1024) NOT NULL DEFAULT '', + application_scenario VARCHAR(255) NOT NULL DEFAULT '', + material VARCHAR(255) NOT NULL DEFAULT '', + manufacturing_method VARCHAR(255) NOT NULL DEFAULT '', + unit VARCHAR(64) NOT NULL DEFAULT '', + user_requirement TEXT, + design_description TEXT, + convert_status VARCHAR(32) NOT NULL DEFAULT 'pending', + error_message TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_cad_model_category_filename (category_id, source_sw_filename) + ) + """); + jdbcTemplate.execute(""" + CREATE TABLE cad_model_history ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + model_id BIGINT NOT NULL, + source_feature_id VARCHAR(128) NOT NULL DEFAULT '', + history_index INT NOT NULL, + history_name VARCHAR(255) NOT NULL DEFAULT '', + operation_type VARCHAR(64) NOT NULL DEFAULT '', + step_path VARCHAR(1024) NOT NULL DEFAULT '', + glb_path VARCHAR(1024) NOT NULL DEFAULT '', + convert_status VARCHAR(32) NOT NULL DEFAULT 'pending', + error_message TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_model_feature (model_id, source_feature_id) + ) + """); + } + + @Test + void listModelsFiltersPaginatesAndSortsByCreatedAtDescending() { + insertModel(1L, 7L, "轴承座 A", "a.sldprt", "success", "2026-07-08 10:00:00"); + insertModel(2L, 7L, "轴承座 B", "b.sldprt", "success", "2026-07-08 11:00:00"); + insertModel(3L, 7L, "连接板", "c.sldprt", "failed", "2026-07-08 12:00:00"); + insertModel(4L, 9L, "轴承座 C", "d.sldprt", "success", "2026-07-08 13:00:00"); + + PageResponse response = cadModelService.listModels( + new CadModelListQuery(1, 1, 7L, "success", "轴承") + ); + + assertThat(response.page()).isEqualTo(1); + assertThat(response.size()).isEqualTo(1); + assertThat(response.total()).isEqualTo(2); + assertThat(response.items()).extracting(CadModelResponse::id).containsExactly(2L); + } + + @Test + void listModelsRejectsInvalidQueryValues() { + assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(0, 10, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("page 必须大于等于 1"); + + assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 101, null, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("size 必须在 1 到 100 之间"); + + assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 10, 0L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("categoryId 必须为正数"); + + assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 10, null, "done", null))) + .isInstanceOf(BizException.class) + .hasMessage("convertStatus 取值非法"); + } + + @Test + void getModelReturnsModelOrNotFound() { + insertModel(1L, 7L, "轴承座", "a.sldprt", "success", "2026-07-08 10:00:00"); + + CadModelResponse response = cadModelService.getModel(1L); + + assertThat(response.id()).isEqualTo(1L); + assertThat(response.modelName()).isEqualTo("轴承座"); + assertThat(response.sourceSwFilename()).isEqualTo("a.sldprt"); + + assertThatThrownBy(() -> cadModelService.getModel(404L)) + .isInstanceOf(BizException.class) + .hasMessage("CAD 模型不存在"); + } + + @Test + void updateModelPartiallyAndIgnoresNullFields() { + insertModel(1L, 7L, "旧名称", "a.sldprt", "success", "2026-07-08 10:00:00"); + UpdateCadModelRequest request = new UpdateCadModelRequest( + 8L, + "新名称", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "partial_failed", + null + ); + + CadModelResponse response = cadModelService.updateModel(1L, request); + + assertThat(response.modelName()).isEqualTo("新名称"); + assertThat(response.categoryId()).isEqualTo(8L); + assertThat(response.convertStatus()).isEqualTo("partial_failed"); + assertThat(response.userRequirement()).isEqualTo("需求描述"); + assertThat(jdbcTemplate.queryForObject( + "SELECT user_requirement FROM cad_model WHERE id = 1", + String.class + )).isEqualTo("需求描述"); + } + + @Test + void updateModelRejectsInvalidAndDuplicateValues() { + insertModel(1L, 7L, "模型 A", "a.sldprt", "success", "2026-07-08 10:00:00"); + insertModel(2L, 7L, "模型 B", "b.sldprt", "success", "2026-07-08 11:00:00"); + + assertThatThrownBy(() -> cadModelService.updateModel(1L, new UpdateCadModelRequest( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "done", + null + ))) + .isInstanceOf(BizException.class) + .hasMessage("convertStatus 取值非法"); + + assertThatThrownBy(() -> cadModelService.updateModel(2L, new UpdateCadModelRequest( + null, + null, + "a.sldprt", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ))) + .isInstanceOf(BizException.class) + .hasMessage("同分类下源 SolidWorks 文件名已存在"); + } + + @Test + void listHistoriesReturnsHistoryIndexOrderAndRequiresExistingModel() { + insertModel(1L, 7L, "轴承座", "a.sldprt", "success", "2026-07-08 10:00:00"); + insertHistory(11L, 1L, "feat_002", 2, "倒角1"); + insertHistory(10L, 1L, "feat_001", 1, "凸台-拉伸1"); + + List histories = cadModelService.listHistories(1L); + + assertThat(histories).extracting(CadModelHistoryResponse::id).containsExactly(10L, 11L); + assertThat(histories).extracting(CadModelHistoryResponse::historyIndex).containsExactly(1, 2); + + assertThatThrownBy(() -> cadModelService.listHistories(404L)) + .isInstanceOf(BizException.class) + .hasMessage("CAD 模型不存在"); + } + + private void insertModel(Long id, Long categoryId, String modelName, String filename, String status, String createdAt) { + jdbcTemplate.update(""" + INSERT INTO cad_model ( + id, category_id, model_name, source_sw_filename, source_sw_path, + feature_tree_json_path, view_path, application_scenario, material, + manufacturing_method, unit, user_requirement, design_description, + convert_status, error_message, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + id, + categoryId, + modelName, + filename, + "sw/" + filename, + "features/" + filename + ".json", + "views/" + filename, + "工业夹具", + "铝合金", + "CNC", + "mm", + "需求描述", + "设计说明", + status, + null, + createdAt, + createdAt + ); + } + + private void insertHistory(Long id, Long modelId, String sourceFeatureId, int historyIndex, String historyName) { + jdbcTemplate.update(""" + INSERT INTO cad_model_history ( + id, model_id, source_feature_id, history_index, history_name, + operation_type, step_path, glb_path, 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" + ); + } +}