模型列表和建模历史接口

This commit is contained in:
lxp
2026-07-08 15:06:28 +08:00
parent 234a166924
commit 3f30d574d6
16 changed files with 1024 additions and 3 deletions
+9
View File
@@ -0,0 +1,9 @@
## 代码实现原则
- 优先保证业务正确性、可维护性、可读性,再考虑炫技。
- 修改代码时忽略.sql文件,如果遇到需要改表结构,直接发我sql。
## 日志规范
- 关键业务节点必须补充必要日志,方便线上排查。
- 不要打印密码、Token、手机号、身份证、支付密钥等敏感信息。
- 异常日志必须包含足够定位问题的信息。
## 回复规范
- 永远用中文回复。
@@ -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 {
@@ -0,0 +1,63 @@
package com.cadlabel.controller;
import com.cadlabel.common.api.ApiResponse;
import com.cadlabel.common.api.PageResponse;
import com.cadlabel.dto.cad.CadModelHistoryResponse;
import com.cadlabel.dto.cad.CadModelListQuery;
import com.cadlabel.dto.cad.CadModelResponse;
import com.cadlabel.dto.cad.UpdateCadModelRequest;
import com.cadlabel.service.CadModelService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping("/api/cad-models")
@RequiredArgsConstructor
public class CadModelController {
private final CadModelService cadModelService;
@GetMapping
public ApiResponse<PageResponse<CadModelResponse>> listModels(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
@RequestParam(required = false) @Positive Long categoryId,
@RequestParam(required = false) String convertStatus,
@RequestParam(required = false) String keyword
) {
return ApiResponse.success(cadModelService.listModels(
new CadModelListQuery(page, size, categoryId, convertStatus, keyword)
));
}
@GetMapping("/{id}")
public ApiResponse<CadModelResponse> getModel(@PathVariable @Positive Long id) {
return ApiResponse.success(cadModelService.getModel(id));
}
@PostMapping("/{id}")
public ApiResponse<CadModelResponse> updateModel(
@PathVariable @Positive Long id,
@Valid @RequestBody(required = false) UpdateCadModelRequest request
) {
return ApiResponse.success(cadModelService.updateModel(id, request));
}
@GetMapping("/{modelId}/histories")
public ApiResponse<List<CadModelHistoryResponse>> listHistories(@PathVariable @Positive Long modelId) {
return ApiResponse.success(cadModelService.listHistories(modelId));
}
}
@@ -0,0 +1,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
) {
}
@@ -0,0 +1,10 @@
package com.cadlabel.dto.cad;
public record CadModelListQuery(
int page,
int size,
Long categoryId,
String convertStatus,
String keyword
) {
}
@@ -0,0 +1,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
) {
}
@@ -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
) {
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,9 @@
package com.cadlabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cadlabel.entity.CadModelHistory;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CadModelHistoryMapper extends BaseMapper<CadModelHistory> {
}
@@ -0,0 +1,9 @@
package com.cadlabel.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.cadlabel.entity.CadModel;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CadModelMapper extends BaseMapper<CadModel> {
}
@@ -0,0 +1,19 @@
package com.cadlabel.service;
import com.cadlabel.common.api.PageResponse;
import com.cadlabel.dto.cad.CadModelHistoryResponse;
import com.cadlabel.dto.cad.CadModelListQuery;
import com.cadlabel.dto.cad.CadModelResponse;
import com.cadlabel.dto.cad.UpdateCadModelRequest;
import java.util.List;
public interface CadModelService {
PageResponse<CadModelResponse> listModels(CadModelListQuery query);
CadModelResponse getModel(Long id);
CadModelResponse updateModel(Long id, UpdateCadModelRequest request);
List<CadModelHistoryResponse> listHistories(Long modelId);
}
@@ -0,0 +1,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 模型服务实现。
*
* <p>负责模型列表、详情、基础信息修改和建模历史查询。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CadModelServiceImpl implements CadModelService {
private static final int DEFAULT_PAGE = 1;
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 100;
private static final Set<String> MODEL_CONVERT_STATUSES = Set.of("pending", "success", "failed", "partial_failed");
private final CadModelMapper cadModelMapper;
private final CadModelHistoryMapper cadModelHistoryMapper;
/**
* 分页查询 CAD 模型列表。
*/
@Override
public PageResponse<CadModelResponse> listModels(CadModelListQuery query) {
int page = resolvePage(query == null ? DEFAULT_PAGE : query.page());
int size = resolveSize(query == null ? DEFAULT_SIZE : query.size());
Long categoryId = query == null ? null : query.categoryId();
String convertStatus = normalizeOptionalText(query == null ? null : query.convertStatus());
String keyword = normalizeOptionalText(query == null ? null : query.keyword());
if (categoryId != null && categoryId <= 0) {
throw invalidRequest("categoryId 必须为正数");
}
if (StringUtils.hasText(convertStatus)) {
validateModelConvertStatus(convertStatus);
}
Page<CadModel> result = cadModelMapper.selectPage(new Page<>(page, size), new LambdaQueryWrapper<CadModel>()
.eq(categoryId != null, CadModel::getCategoryId, categoryId)
.eq(StringUtils.hasText(convertStatus), CadModel::getConvertStatus, convertStatus)
.and(StringUtils.hasText(keyword), nested -> nested
.like(CadModel::getModelName, keyword)
.or()
.like(CadModel::getSourceSwFilename, keyword))
.orderByDesc(CadModel::getCreatedAt)
.orderByDesc(CadModel::getId));
List<CadModelResponse> items = result.getRecords().stream().map(this::toModelResponse).toList();
return new PageResponse<>(items, result.getCurrent(), result.getSize(), result.getTotal());
}
/**
* 获取 CAD 模型详情。
*/
@Override
public CadModelResponse getModel(Long id) {
return toModelResponse(requireModel(id));
}
/**
* 修改 CAD 模型基础信息。
*/
@Override
@Transactional
public CadModelResponse updateModel(Long id, UpdateCadModelRequest request) {
CadModel model = requireModel(id);
List<String> updatedFields = updatedFields(request);
if (updatedFields.isEmpty()) {
return toModelResponse(model);
}
validateUpdateRequest(request);
ensureCategoryFilenameUnique(id, model, request);
applyUpdate(model, request);
model.setUpdatedAt(LocalDateTime.now());
log.info("Updating CAD model. id={}, fields={}", id, updatedFields);
try {
cadModelMapper.updateById(model);
} catch (DuplicateKeyException exception) {
throw duplicateModelException();
}
return getModel(id);
}
/**
* 查询模型完整建模历史。
*/
@Override
public List<CadModelHistoryResponse> listHistories(Long modelId) {
requireModel(modelId);
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<String> updatedFields(UpdateCadModelRequest request) {
List<String> fields = new ArrayList<>();
if (request == null) {
return fields;
}
addField(fields, "categoryId", request.categoryId());
addField(fields, "modelName", request.modelName());
addField(fields, "sourceSwFilename", request.sourceSwFilename());
addField(fields, "sourceSwPath", request.sourceSwPath());
addField(fields, "featureTreeJsonPath", request.featureTreeJsonPath());
addField(fields, "viewPath", request.viewPath());
addField(fields, "applicationScenario", request.applicationScenario());
addField(fields, "material", request.material());
addField(fields, "manufacturingMethod", request.manufacturingMethod());
addField(fields, "unit", request.unit());
addField(fields, "userRequirement", request.userRequirement());
addField(fields, "designDescription", request.designDescription());
addField(fields, "convertStatus", request.convertStatus());
addField(fields, "errorMessage", request.errorMessage());
return fields;
}
private void addField(List<String> fields, String fieldName, Object value) {
if (value != null) {
fields.add(fieldName);
}
}
private String normalizeOptionalText(String value) {
return StringUtils.hasText(value) ? value.trim() : "";
}
private void validateModelConvertStatus(String status) {
if (!MODEL_CONVERT_STATUSES.contains(status)) {
throw invalidRequest("convertStatus 取值非法");
}
}
private BizException invalidRequest(String message) {
return new BizException(HttpStatus.UNPROCESSABLE_ENTITY.value(), ApiCode.INVALID_REQUEST, message);
}
private BizException modelNotFound() {
return new BizException(HttpStatus.NOT_FOUND.value(), ApiCode.NOT_FOUND, "CAD 模型不存在");
}
private BizException duplicateModelException() {
return new BizException(HttpStatus.CONFLICT.value(), "CAD_MODEL_DUPLICATE", "同分类下源 SolidWorks 文件名已存在");
}
private CadModelResponse toModelResponse(CadModel model) {
return new CadModelResponse(
model.getId(),
model.getCategoryId(),
model.getModelName(),
model.getSourceSwFilename(),
model.getSourceSwPath(),
model.getFeatureTreeJsonPath(),
model.getViewPath(),
model.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()
);
}
}
+1 -1
View File
@@ -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
@@ -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)
);
}
}
@@ -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<CadModelResponse> response = cadModelService.listModels(
new CadModelListQuery(1, 1, 7L, "success", "轴承")
);
assertThat(response.page()).isEqualTo(1);
assertThat(response.size()).isEqualTo(1);
assertThat(response.total()).isEqualTo(2);
assertThat(response.items()).extracting(CadModelResponse::id).containsExactly(2L);
}
@Test
void listModelsRejectsInvalidQueryValues() {
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(0, 10, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("page 必须大于等于 1");
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 101, null, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("size 必须在 1 到 100 之间");
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 10, 0L, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("categoryId 必须为正数");
assertThatThrownBy(() -> cadModelService.listModels(new CadModelListQuery(1, 10, null, "done", null)))
.isInstanceOf(BizException.class)
.hasMessage("convertStatus 取值非法");
}
@Test
void getModelReturnsModelOrNotFound() {
insertModel(1L, 7L, "轴承座", "a.sldprt", "success", "2026-07-08 10:00:00");
CadModelResponse response = cadModelService.getModel(1L);
assertThat(response.id()).isEqualTo(1L);
assertThat(response.modelName()).isEqualTo("轴承座");
assertThat(response.sourceSwFilename()).isEqualTo("a.sldprt");
assertThatThrownBy(() -> cadModelService.getModel(404L))
.isInstanceOf(BizException.class)
.hasMessage("CAD 模型不存在");
}
@Test
void updateModelPartiallyAndIgnoresNullFields() {
insertModel(1L, 7L, "旧名称", "a.sldprt", "success", "2026-07-08 10:00:00");
UpdateCadModelRequest request = new UpdateCadModelRequest(
8L,
"新名称",
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
"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<CadModelHistoryResponse> 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"
);
}
}