deepdata: core product code (backend, frontend, runtime, deepflow module)

This commit is contained in:
figmar
2026-08-30 19:23:38 +08:00
commit bd3db30be9
1405 changed files with 271727 additions and 0 deletions
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.deepdata</groupId>
<artifactId>services</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>data-management-service</artifactId>
<name>Data Management Service</name>
<description>数据管理服务</description>
<dependencies>
<dependency>
<groupId>com.deepdata</groupId>
<artifactId>domain-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<arguments>true</arguments>
<classifier>exec</classifier>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<annotationProcessorPaths>
<!-- 顺序很重要 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-parameters</arg>
<arg>-Amapstruct.defaultComponentModel=spring</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,20 @@
package com.deepdata.datamanagement;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Data Management Service Configuration
* 数据管理服务配置类 - 多源接入、元数据、血缘治理
*/
@Configuration
@EnableAsync
@ComponentScan(basePackages = {
"com.deepdata.datamanagement",
"com.deepdata.shared"
})
public class DataManagementServiceConfiguration {
// Service configuration class for JAR packaging
// 作为jar包形式提供服务的配置类
}
@@ -0,0 +1,352 @@
package com.deepdata.datamanagement.application;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.deepdata.common.domain.enums.EdgeType;
import com.deepdata.common.domain.enums.NodeType;
import com.deepdata.common.domain.model.LineageEdge;
import com.deepdata.common.domain.model.LineageNode;
import com.deepdata.common.domain.service.LineageService;
import com.deepdata.common.domain.utils.ChunksSaver;
import com.deepdata.common.setting.application.SysParamApplicationService;
import com.deepdata.datamanagement.interfaces.dto.*;
import com.deepdata.common.infrastructure.exception.BusinessAssert;
import com.deepdata.common.interfaces.PagedResponse;
import com.deepdata.common.infrastructure.common.Response;
import com.deepdata.datamanagement.domain.model.dataset.Dataset;
import com.deepdata.datamanagement.domain.model.dataset.DatasetFile;
import com.deepdata.datamanagement.domain.model.dataset.Tag;
import com.deepdata.datamanagement.infrastructure.client.CollectionTaskClient;
import com.deepdata.datamanagement.infrastructure.client.dto.CollectionTaskDetailResponse;
import com.deepdata.datamanagement.infrastructure.exception.DataManagementErrorCode;
import com.deepdata.datamanagement.infrastructure.persistence.mapper.TagMapper;
import com.deepdata.datamanagement.infrastructure.persistence.repository.DatasetFileRepository;
import com.deepdata.datamanagement.infrastructure.persistence.repository.DatasetRepository;
import com.deepdata.datamanagement.interfaces.converter.DatasetConverter;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 数据集应用服务(对齐 DB schema,使用 UUID 字符串主键)
*/
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class DatasetApplicationService {
private static final String DATASET_PVC_NAME = "sys.management.dataset.pvc.name";
private final DatasetRepository datasetRepository;
private final TagMapper tagMapper;
private final DatasetFileRepository datasetFileRepository;
private final CollectionTaskClient collectionTaskClient;
private final DatasetFileApplicationService datasetFileApplicationService;
private final SysParamApplicationService sysParamService;
private final LineageService lineageService;
@Value("${deepdata.data-management.base-path:/dataset}")
private String datasetBasePath;
/**
* 创建数据集
*/
@Transactional
public Dataset createDataset(CreateDatasetRequest createDatasetRequest) {
BusinessAssert.isTrue(datasetRepository.findByName(createDatasetRequest.getName()) == null, DataManagementErrorCode.DATASET_ALREADY_EXISTS);
// 创建数据集对象
Dataset dataset = DatasetConverter.INSTANCE.convertToDataset(createDatasetRequest);
dataset.initCreateParam(datasetBasePath);
// xuhuanqing
// ================= 新增代码 START =================
// 防止后续调用归集任务接口时因 createdBy 为 null 报错
// 实际项目中应从当前登录用户的 Session/Token 获取,这里先设为默认值
dataset.setCreatedBy("admin");
dataset.setUpdatedBy("admin");
// ================= 新增代码 END =================
// 处理标签
if (CollectionUtils.isNotEmpty(createDatasetRequest.getTags())) {
dataset.setTags(processTagNames(createDatasetRequest.getTags()));
}
datasetRepository.save(dataset);
// 记录血缘关系
addDatasetToGraph(dataset, null);
//todo 需要解耦这块逻辑
if (StringUtils.hasText(createDatasetRequest.getDataSource())) {
// 数据源id不为空,使用异步线程进行文件扫盘落库
processDataSourceAsync(dataset.getId(), createDatasetRequest.getDataSource());
}
return dataset;
}
private void addDatasetToGraph(Dataset dataset, CollectionTaskDetailResponse collection) {
LineageNode datasetNode = new LineageNode();
datasetNode.setId(dataset.getId());
datasetNode.setNodeType(NodeType.DATASET);
datasetNode.setName(dataset.getName());
datasetNode.setDescription(dataset.getDescription());
LineageNode collectionNode = null;
LineageEdge collectionEdge = null;
if(Objects.nonNull(collection)) {
collectionNode = new LineageNode();
collectionNode.setId(collection.getId());
collectionNode.setName(collection.getName());
collectionNode.setDescription(collection.getDescription());
collectionNode.setNodeType(NodeType.DATASOURCE);
collectionEdge = new LineageEdge();
collectionEdge.setProcessId(collection.getId());
collectionEdge.setName(collection.getName());
collectionEdge.setEdgeType(EdgeType.DATA_COLLECTION);
collectionEdge.setDescription(dataset.getDescription());
collectionEdge.setFromNodeId(collectionNode.getId());
collectionEdge.setToNodeId(datasetNode.getId());
lineageService.generateGraph(collectionNode, collectionEdge, datasetNode);
} else {
lineageService.generateGraph(datasetNode, null, null);
}
}
public DatasetLineage getDatasetLineage(String datasetId) {
Dataset dataset = datasetRepository.getById(datasetId);
if (Objects.isNull(dataset)) {
return new DatasetLineage();
}
LineageNode datasetNode = lineageService.getNodeById(datasetId);
String graphId = datasetNode.getGraphId();
return new DatasetLineage(lineageService.getNodesByGraphId(graphId), lineageService.getEdgesByGraphId(graphId));
}
public String getDatasetPvcName() {
return sysParamService.getParamByKey(DATASET_PVC_NAME);
}
public Dataset updateDataset(String datasetId, UpdateDatasetRequest updateDatasetRequest) {
Dataset dataset = datasetRepository.getById(datasetId);
BusinessAssert.notNull(dataset, DataManagementErrorCode.DATASET_NOT_FOUND);
if (StringUtils.hasText(updateDatasetRequest.getName())) {
dataset.setName(updateDatasetRequest.getName());
}
if (StringUtils.hasText(updateDatasetRequest.getDescription())) {
dataset.setDescription(updateDatasetRequest.getDescription());
}
if (CollectionUtils.isNotEmpty(updateDatasetRequest.getTags())) {
dataset.setTags(processTagNames(updateDatasetRequest.getTags()));
}
if (Objects.nonNull(updateDatasetRequest.getStatus())) {
dataset.setStatus(updateDatasetRequest.getStatus());
}
datasetRepository.updateById(dataset);
if (StringUtils.hasText(updateDatasetRequest.getDataSource())) {
// 数据源id不为空,使用异步线程进行文件扫盘落库
processDataSourceAsync(dataset.getId(), updateDatasetRequest.getDataSource());
}
return dataset;
}
/**
* 删除数据集
*/
@Transactional
public void deleteDataset(String datasetId) {
Dataset dataset = datasetRepository.getById(datasetId);
datasetRepository.removeById(datasetId);
if (dataset != null) {
ChunksSaver.deleteFolder(dataset.getPath());
}
}
/**
* 获取数据集详情
*/
@Transactional(readOnly = true)
public Dataset getDataset(String datasetId) {
Dataset dataset = datasetRepository.getById(datasetId);
BusinessAssert.notNull(dataset, DataManagementErrorCode.DATASET_NOT_FOUND);
List<DatasetFile> datasetFiles = datasetFileRepository.findAllByDatasetId(datasetId);
dataset.setFiles(datasetFiles);
return dataset;
}
/**
* 分页查询数据集
*/
@Transactional(readOnly = true)
public PagedResponse<DatasetResponse> getDatasets(DatasetPagingQuery query) {
IPage<Dataset> page = new Page<>(query.getPage(), query.getSize());
page = datasetRepository.findByCriteria(page, query);
String datasetPvcName = getDatasetPvcName();
List<DatasetResponse> datasetResponses = DatasetConverter.INSTANCE.convertToResponse(page.getRecords());
datasetResponses.forEach(dataset -> dataset.setPvcName(datasetPvcName));
return PagedResponse.of(page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), datasetResponses);
}
/**
* 处理标签名称,创建或获取标签
*/
private List<Tag> processTagNames(List<String> tagNames) {
Set<Tag> tags = new HashSet<>();
for (String tagName : tagNames) {
Tag tag = tagMapper.findByName(tagName);
if (tag == null) {
Tag newTag = new Tag(tagName, null, null, "#007bff");
newTag.setUsageCount(0L);
newTag.setId(UUID.randomUUID().toString());
tagMapper.insert(newTag);
tag = newTag;
}
tag.setUsageCount(tag.getUsageCount() == null ? 1L : tag.getUsageCount() + 1);
tagMapper.updateUsageCount(tag.getId(), tag.getUsageCount());
tags.add(tag);
}
return tags.stream().toList();
}
/**
* 获取数据集统计信息
*/
@Transactional(readOnly = true)
public Map<String, Object> getDatasetStatistics(String datasetId) {
Dataset dataset = datasetRepository.getById(datasetId);
if (dataset == null) {
throw new IllegalArgumentException("Dataset not found: " + datasetId);
}
Map<String, Object> statistics = new HashMap<>();
// 基础统计
Long totalFiles = datasetFileRepository.countByDatasetId(datasetId);
Long completedFiles = datasetFileRepository.countCompletedByDatasetId(datasetId);
Long totalSize = datasetFileRepository.sumSizeByDatasetId(datasetId);
statistics.put("totalFiles", totalFiles != null ? totalFiles.intValue() : 0);
statistics.put("completedFiles", completedFiles != null ? completedFiles.intValue() : 0);
statistics.put("totalSize", totalSize != null ? totalSize : 0L);
// 完成率计算
float completionRate = 0.0f;
if (totalFiles != null && totalFiles > 0) {
completionRate = (completedFiles != null ? completedFiles.floatValue() : 0.0f) / totalFiles.floatValue() * 100.0f;
}
statistics.put("completionRate", completionRate);
// 文件类型分布统计
Map<String, Integer> fileTypeDistribution = new HashMap<>();
List<DatasetFile> allFiles = datasetFileRepository.findAllByDatasetId(datasetId);
if (allFiles != null) {
for (DatasetFile file : allFiles) {
String fileType = file.getFileType() != null ? file.getFileType() : "unknown";
fileTypeDistribution.put(fileType, fileTypeDistribution.getOrDefault(fileType, 0) + 1);
}
}
statistics.put("fileTypeDistribution", fileTypeDistribution);
// 状态分布统计
Map<String, Integer> statusDistribution = new HashMap<>();
if (allFiles != null) {
for (DatasetFile file : allFiles) {
String status = file.getStatus() != null ? file.getStatus() : "unknown";
statusDistribution.put(status, statusDistribution.getOrDefault(status, 0) + 1);
}
}
statistics.put("statusDistribution", statusDistribution);
return statistics;
}
/**
* 获取所有数据集的汇总统计信息
*/
public AllDatasetStatisticsResponse getAllDatasetStatistics() {
return datasetRepository.getAllDatasetStatistics();
}
/**
* 异步处理数据源文件扫描
*
* @param datasetId 数据集ID
* @param dataSourceId 数据源ID(归集任务ID)
*/
@Async
public void processDataSourceAsync(String datasetId, String dataSourceId) {
try {
log.info("Initiating data source file scanning, dataset ID: {}, collection task ID: {}", datasetId, dataSourceId);
List<String> filePaths = getFilePaths(dataSourceId, datasetRepository.getById(datasetId));
if (CollectionUtils.isEmpty(filePaths)) {
return;
}
// 创建请求并启用硬链接(softAdd=true)
AddFilesRequest addFilesRequest = new AddFilesRequest(filePaths);
addFilesRequest.setSoftAdd(true);
datasetFileApplicationService.addFilesToDataset(datasetId, addFilesRequest);
log.info("Success file scan, total files: {}", filePaths.size());
} catch (Exception e) {
log.error("处理数据源文件扫描失败,数据集ID: {}, 数据源ID: {}", datasetId, dataSourceId, e);
}
}
private List<String> getFilePaths(String dataSourceId, Dataset dataset) {
// xuhuanqing
// ================= 修改代码 START =================
// 防御性编程:如果 createdBy 为空,使用 "system" 代替,防止 Feign Header 报错
String user = "system";
if (dataset != null && dataset.getCreatedBy() != null) {
user = dataset.getCreatedBy();
}
// 将原来的 dataset.getCreatedBy() 替换为变量 user
Response<CollectionTaskDetailResponse> response = collectionTaskClient.getTaskDetail(dataSourceId, user);
// 检查响应状态
if (response == null || response.getData() == null) {
log.warn("Fail to get collection task detail from Feign client, response is empty");
return Collections.emptyList();
}
CollectionTaskDetailResponse taskDetail = response.getData();
// ================= 修改代码 END =================
// CollectionTaskDetailResponse taskDetail = collectionTaskClient.getTaskDetail(dataSourceId, dataset.getCreatedBy()).getData();
if (taskDetail == null) {
log.warn("Fail to get collection task detail, task ID: {}", dataSourceId);
return Collections.emptyList();
}
// 记录血缘关系
addDatasetToGraph(dataset, taskDetail);
Path targetPath = Paths.get(taskDetail.getTargetPath());
if (!Files.exists(targetPath) || !Files.isDirectory(targetPath)) {
log.warn("Target path not exists or is not a directory: {}", taskDetail.getTargetPath());
return Collections.emptyList();
}
try (Stream<Path> paths = Files.walk(targetPath, 1)) {
return paths
.filter(Files::isRegularFile) // 只保留文件,排除目录
.map(Path::toString) // 转换为字符串路径
.collect(Collectors.toList());
} catch (IOException e) {
log.error("Fail to scan directory: {}", targetPath, e);
return Collections.emptyList();
}
}
}
@@ -0,0 +1,118 @@
package com.deepdata.datamanagement.application;
import com.deepdata.common.infrastructure.exception.BusinessException;
import com.deepdata.datamanagement.domain.model.dataset.Tag;
import com.deepdata.datamanagement.infrastructure.exception.DataManagementErrorCode;
import com.deepdata.datamanagement.infrastructure.persistence.mapper.TagMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.util.List;
import java.util.UUID;
/**
* 标签应用服务(UUID 主键)
*/
@Service
@Transactional
public class TagApplicationService {
private final TagMapper tagMapper;
@Autowired
public TagApplicationService(TagMapper tagMapper) {
this.tagMapper = tagMapper;
}
/**
* 创建标签
*/
public Tag createTag(String name, String color, String description) {
// 检查名称是否已存在
if (tagMapper.findByName(name) != null) {
throw BusinessException.of(DataManagementErrorCode.TAG_NAME_DUPLICATE);
}
Tag tag = new Tag(name, description, null, color);
tag.setUsageCount(0L);
tag.setId(UUID.randomUUID().toString());
tagMapper.insert(tag);
return tagMapper.findById(tag.getId());
}
/**
* 更新标签
*
* @param tag 待更新的标签实体,必须包含有效的 ID
* @return 更新结果
*/
@Transactional
public Tag updateTag(Tag tag) {
Tag existingTag = tagMapper.findById(tag.getId());
if (existingTag == null) {
throw new IllegalArgumentException("Tag not found: " + tag.getId());
}
existingTag.setName(tag.getName());
existingTag.setColor(tag.getColor());
existingTag.setDescription(tag.getDescription());
tagMapper.update(existingTag);
return tagMapper.findById(existingTag.getId());
}
@Transactional
public void deleteTag(List<String> tagIds) {
List<Tag> tags = tagMapper.findByIdIn(tagIds);
if (tags.stream().anyMatch(tag -> tag.getUsageCount() > 0)) {
throw new IllegalArgumentException("Cannot delete tags that are in use");
}
if (CollectionUtils.isEmpty(tags)) {
return;
}
tagMapper.deleteTagsById(tags.stream().map(Tag::getId).toList());
}
/**
* 获取所有标签
*/
@Transactional(readOnly = true)
public List<Tag> getAllTags() {
return tagMapper.findAllByOrderByUsageCountDesc();
}
/**
* 根据关键词搜索标签
*/
@Transactional(readOnly = true)
public List<Tag> searchTags(String keyword) {
if (keyword == null || keyword.trim().isEmpty()) {
return getAllTags();
}
return tagMapper.findByKeyword(keyword.trim());
}
/**
* 获取标签详情
*/
@Transactional(readOnly = true)
public Tag getTag(String tagId) {
Tag tag = tagMapper.findById(tagId);
if (tag == null) {
throw new IllegalArgumentException("Tag not found: " + tagId);
}
return tag;
}
/**
* 根据名称获取标签
*/
@Transactional(readOnly = true)
public Tag getTagByName(String name) {
Tag tag = tagMapper.findByName(name);
if (tag == null) {
throw new IllegalArgumentException("Tag not found: " + name);
}
return tag;
}
}
@@ -0,0 +1,41 @@
package com.deepdata.datamanagement.common.enums;
/**
* 数据集状态类型
* <p>数据集可以处于以下几种状态:
* <p>草稿(DRAFT):数据集正在创建中,尚未完成。
* <p>活动(ACTIVE):数据集处于活动状态, 可以被查询和使用,也可以被更新和删除。
* <p>处理中(PROCESSING):数据集正在处理中,可能需要一些时间,处理完成后会变成活动状态。
* <p>已归档(ARCHIVED):数据集已被归档,不可以更新文件,可以解锁变成活动状态。
* <p>已发布(PUBLISHED):数据集已被发布,可供外部使用,外部用户可以查询和使用数据集。
* <p>已弃用(DEPRECATED):数据集已被弃用,不建议再使用。
*
* @author dallas
* @since 2025-10-17
*/
public enum DatasetStatusType {
/**
* 草稿状态
*/
DRAFT,
/**
* 活动状态
*/
ACTIVE,
/**
* 处理中状态
*/
PROCESSING,
/**
* 已归档状态
*/
ARCHIVED,
/**
* 已发布状态
*/
PUBLISHED,
/**
* 已弃用状态
*/
DEPRECATED
}
@@ -0,0 +1,28 @@
package com.deepdata.datamanagement.common.enums;
import lombok.Getter;
/**
* 数据集类型值对象
*
* @author DeepData
* @since 2025-10-15
*/
public enum DatasetType {
TEXT("text", "文本数据集"),
IMAGE("image", "图像数据集"),
AUDIO("audio", "音频数据集"),
VIDEO("video", "视频数据集"),
OTHER("other", "其他数据集");
@Getter
private final String code;
@Getter
private final String description;
DatasetType(String code, String description) {
this.code = code;
this.description = description;
}
}
@@ -0,0 +1,11 @@
package com.deepdata.datamanagement.common.enums;
/**
* 文件重名时的处理方式
*
* @since 2025/11/18
*/
public enum DuplicateMethod {
ERROR,
COVER
}
@@ -0,0 +1,11 @@
package com.deepdata.datamanagement.domain.contants;
/**
* 数据集常量
*/
public interface DatasetConstant {
/**
* 服务ID
*/
String SERVICE_ID = "DATA_MANAGEMENT";
}
@@ -0,0 +1,155 @@
package com.deepdata.datamanagement.domain.model.dataset;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.deepdata.common.domain.model.base.BaseEntity;
import com.deepdata.common.infrastructure.config.PgJsonTypeHandler;
import com.deepdata.datamanagement.common.enums.DatasetStatusType;
import com.deepdata.datamanagement.common.enums.DatasetType;
import lombok.Getter;
import lombok.Setter;
import java.io.File;
import java.time.LocalDateTime;
import java.util.*;
/**
* 数据集实体(与数据库表 t_dm_datasets 对齐)
*/
@Getter
@Setter
@TableName(value = "t_dm_datasets", autoResultMap = true)
public class Dataset extends BaseEntity<String> {
/**
* 数据集名称
*/
private String name;
/**
* 数据集描述
*/
private String description;
/**
* 数据集类型
*/
private DatasetType datasetType;
/**
* 数据集分类
*/
private String category;
/**
* 数据集路径
*/
private String path;
/**
* 数据集格式
*/
private String format;
/**
* 数据集模式信息,JSON格式, 用于解析当前数据集的文件结构
*/
private String schemaInfo;
/**
* 数据集大小(字节)
*/
private Long sizeBytes = 0L;
/**
* 文件数量
*/
private Long fileCount = 0L;
/**
* 记录数量
*/
private Long recordCount = 0L;
/**
* 数据集保留天数
*/
private Integer retentionDays = 0;
/**
* 标签列表, JSON格式
*/
@TableField(typeHandler = PgJsonTypeHandler.class)
private List<Tag> tags;
/**
* 额外元数据,JSON格式
*/
@TableField(typeHandler = PgJsonTypeHandler.class)
private String metadata;
/**
* 数据集状态
*/
private DatasetStatusType status;
/**
* 是否为公共数据集
*/
private Boolean isPublic = false;
/**
* 是否为精选数据集
*/
private Boolean isFeatured = false;
/**
* 数据集版本号
*/
private Long version = 0L;
@TableField(exist = false)
private List<DatasetFile> files = new ArrayList<>();
public Dataset() {
}
public Dataset(String name, String description, DatasetType datasetType, String category, String path,
String format, DatasetStatusType status, String createdBy) {
this.name = name;
this.description = description;
this.datasetType = datasetType;
this.category = category;
this.path = path;
this.format = format;
this.status = status;
this.createdBy = createdBy;
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public void initCreateParam(String datasetBasePath) {
this.id = UUID.randomUUID().toString();
this.path = datasetBasePath + File.separator + this.id;
if (this.status == null) {
this.status = DatasetStatusType.DRAFT;
}
}
public void updateBasicInfo(String name, String description, String category) {
if (name != null && !name.isEmpty()) this.name = name;
if (description != null) this.description = description;
if (category != null) this.category = category;
this.updatedAt = LocalDateTime.now();
}
public void updateStatus(DatasetStatusType status, String updatedBy) {
this.status = status;
this.updatedBy = updatedBy;
this.updatedAt = LocalDateTime.now();
}
public void addFile(DatasetFile file) {
this.files.add(file);
this.fileCount = this.fileCount + 1;
this.sizeBytes = this.sizeBytes + (file.getFileSize() != null ? file.getFileSize() : 0L);
this.updatedAt = LocalDateTime.now();
}
public void removeFile(DatasetFile file) {
if (this.files.remove(file)) {
this.fileCount = Math.max(0, this.fileCount - 1);
this.sizeBytes = Math.max(0, this.sizeBytes - (file.getFileSize() != null ? file.getFileSize() : 0L));
this.updatedAt = LocalDateTime.now();
}
}
public void active() {
if (this.status == DatasetStatusType.DRAFT) {
this.status = DatasetStatusType.ACTIVE;
}
}
}
@@ -0,0 +1,65 @@
package com.deepdata.datamanagement.domain.model.dataset;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.deepdata.common.infrastructure.config.PgJsonTypeHandler;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
/**
* 数据集文件实体(与数据库表 t_dm_dataset_files 对齐)
*/
@Getter
@Setter
@Builder
@Slf4j
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_dm_dataset_files")
public class DatasetFile {
@TableId
private String id; // UUID
private String datasetId; // UUID
private String fileName;
private String filePath;
private String fileType; // JPG/PNG/DCM/TXT
private Long fileSize; // bytes
private String checkSum;
private String tags;
@TableField(typeHandler = PgJsonTypeHandler.class)
private String metadata;
private String status; // UPLOADED, PROCESSING, COMPLETED, ERROR
private LocalDateTime uploadTime;
private LocalDateTime lastAccessTime;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 标记是否为目录(非持久化字段) */
@TableField(exist = false)
private Boolean directory;
/** 目录包含的文件数量(非持久化字段) */
@TableField(exist = false)
private Long fileCount;
/**
* 解析标签
*
* @return 标签列表
*/
public List<FileTag> analyzeTag() {
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.readValue(tags, new TypeReference<List<FileTag>>() {});
} catch (Exception e) {
return Collections.emptyList();
}
}
}
@@ -0,0 +1,24 @@
package com.deepdata.datamanagement.domain.model.dataset;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 数据集文件上传检查信息
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DatasetFileUploadCheckInfo {
/** 数据集id */
private String datasetId;
/** 是否为压缩包上传 */
private boolean hasArchive;
/** 目标子目录前缀,例如 "images/",为空表示数据集根目录 */
private String prefix;
}
@@ -0,0 +1,45 @@
package com.deepdata.datamanagement.domain.model.dataset;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class FileTag {
private Map<String, Object> values;
private String type;
private String id;
private String fromName;
public List<String> getTagValues() {
List<String> tags = new ArrayList<>();
Object tagValues = values.get(type);
if (tagValues instanceof List) {
for (Object tag : (List<?>) tagValues) {
if (tag instanceof String) {
tags.add((String) tag);
}
}
} else if (tagValues instanceof String) {
tags.add((String) tagValues);
}
return tags;
}
}
@@ -0,0 +1,33 @@
package com.deepdata.datamanagement.domain.model.dataset;
import com.deepdata.common.domain.model.base.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 标签实体(与数据库表 t_dm_tags 对齐)
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Tag extends BaseEntity<String> {
private String name;
private String description;
private String category;
private String color;
private Long usageCount = 0L;
public Tag(String name, String description, String category, String color) {
this.name = name;
this.description = description;
this.category = category;
this.color = color;
}
public void decrementUsage() {
if (this.usageCount != null && this.usageCount > 0) this.usageCount--;
}
}
@@ -0,0 +1,23 @@
package com.deepdata.datamanagement.infrastructure.client;
import com.deepdata.common.infrastructure.common.Response;
import com.deepdata.datamanagement.infrastructure.client.dto.CollectionTaskDetailResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* 数据归集服务 Feign Client
*/
@FeignClient(name = "collection-service", url = "${collection.service.url:http://deepdata-backend-python:18000}")
public interface CollectionTaskClient {
/**
* 获取归集任务详情
* @param taskId 任务ID
* @return 任务详情
*/
@GetMapping("/api/data-collection/tasks/{id}")
Response<CollectionTaskDetailResponse> getTaskDetail(@PathVariable("id") String taskId, @RequestHeader("User") String authorization);
}
@@ -0,0 +1,24 @@
package com.deepdata.datamanagement.infrastructure.client.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 归集任务详情响应
*/
@Data
public class CollectionTaskDetailResponse {
private String id;
private String name;
private String description;
private String targetPath;
private Map<String, Object> config;
private String status;
private String syncMode;
private String scheduleExpression;
private String lastExecutionId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.deepdata.datamanagement.infrastructure.client.dto;
import lombok.Data;
import java.util.List;
/**
* 本地归集任务配置
*/
@Data
public class LocalCollectionConfig {
/**
* 归集类型
*/
private String type;
/**
* 文件路径列表
*/
private List<String> filePaths;
}
@@ -0,0 +1,37 @@
package com.deepdata.datamanagement.infrastructure.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.multipart.support.StandardServletMultipartResolver;
/**
* 数据管理服务配置
*/
@Configuration
@EnableTransactionManagement
@EnableCaching
@EnableConfigurationProperties(DataManagementProperties.class)
public class DataManagementConfig {
/**
* 缓存管理器
*/
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("datasets", "datasetFiles", "tags");
}
/**
* 文件上传解析器
*/
@Bean
public StandardServletMultipartResolver multipartResolver() {
StandardServletMultipartResolver resolver = new StandardServletMultipartResolver();
return resolver;
}
}
@@ -0,0 +1,94 @@
package com.deepdata.datamanagement.infrastructure.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
/**
* 数据管理服务配置属性
*/
@Configuration
@ConfigurationProperties(prefix = "datamanagement")
public class DataManagementProperties {
private FileStorage fileStorage = new FileStorage();
private Cache cache = new Cache();
public FileStorage getFileStorage() {
return fileStorage;
}
public void setFileStorage(FileStorage fileStorage) {
this.fileStorage = fileStorage;
}
public Cache getCache() {
return cache;
}
public void setCache(Cache cache) {
this.cache = cache;
}
public static class FileStorage {
private String uploadDir = "./uploads";
private long maxFileSize = 10485760; // 10MB
private long maxRequestSize = 52428800; // 50MB
private List<String> allowedSourceDirs = new ArrayList<>();
public String getUploadDir() {
return uploadDir;
}
public void setUploadDir(String uploadDir) {
this.uploadDir = uploadDir;
}
public long getMaxFileSize() {
return maxFileSize;
}
public void setMaxFileSize(long maxFileSize) {
this.maxFileSize = maxFileSize;
}
public long getMaxRequestSize() {
return maxRequestSize;
}
public void setMaxRequestSize(long maxRequestSize) {
this.maxRequestSize = maxRequestSize;
}
public List<String> getAllowedSourceDirs() {
return allowedSourceDirs;
}
public void setAllowedSourceDirs(List<String> allowedSourceDirs) {
this.allowedSourceDirs = allowedSourceDirs;
}
}
public static class Cache {
private int ttl = 3600; // 1 hour
private int maxSize = 1000;
public int getTtl() {
return ttl;
}
public void setTtl(int ttl) {
this.ttl = ttl;
}
public int getMaxSize() {
return maxSize;
}
public void setMaxSize(int maxSize) {
this.maxSize = maxSize;
}
}
}
@@ -0,0 +1,87 @@
package com.deepdata.datamanagement.infrastructure.exception;
import com.deepdata.common.infrastructure.exception.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 数据管理模块错误码
*
* @author dallas
* @since 2025-10-20
*/
@Getter
@AllArgsConstructor
public enum DataManagementErrorCode implements ErrorCode {
/**
* 数据集不存在
*/
DATASET_NOT_FOUND("data_management.0001", "数据集不存在"),
/**
* 数据集已存在
*/
DATASET_ALREADY_EXISTS("data_management.0002", "数据集已存在"),
/**
* 数据集状态错误
*/
DATASET_STATUS_ERROR("data_management.0003", "数据集状态错误"),
/**
* 数据集标签不存在
*/
DATASET_TAG_NOT_FOUND("data_management.0004", "数据集标签不存在"),
/**
* 数据集标签已存在
*/
DATASET_TAG_ALREADY_EXISTS("data_management.0005", "数据集标签已存在"),
/**
* 数据集文件已存在
*/
DATASET_FILE_ALREADY_EXISTS("data_management.0006", "数据集文件已存在"),
/**
* 目录不存在
*/
DIRECTORY_NOT_FOUND("data_management.0007", "目录不存在"),
/**
* 数据集名称长度不能超过255个字符
*/
DATASET_NAME_TOO_LONG("data_management.0008", "数据集名称长度不能超过255个字符"),
/**
* 数据集类型不合法
*/
DATASET_TYPE_INVALID("data_management.0009", "数据集类型不合法"),
/**
* 数据集状态不合法
*/
DATASET_STATUS_INVALID("data_management.0010", "数据集状态不合法"),
/**
* 标签名称长度不能超过100个字符
*/
TAG_NAME_TOO_LONG("data_management.0011", "标签名称长度不能超过100个字符"),
/**
* 标签名称已存在
*/
TAG_NAME_DUPLICATE("data_management.0012", "标签名称已存在"),
/**
* 标签颜色格式不正确,应为十六进制颜色代码
*/
TAG_COLOR_INVALID("data_management.0013", "标签颜色格式不正确,应为十六进制颜色代码"),
/**
* 保留天数必须为非负整数
*/
RETENTION_DAYS_INVALID("data_management.0014", "保留天数必须为非负整数"),
/**
* 路径长度不能超过限制
*/
PATH_TOO_LONG("data_management.0015", "路径长度不能超过限制"),
/**
* 文件名包含非法字符
*/
FILE_NAME_INVALID("data_management.0016", "文件名包含非法字符"),
/**
* 路径前缀不能以点开头
*/
PREFIX_INVALID("data_management.0017", "路径前缀不能以点开头");
private final String code;
private final String message;
}
@@ -0,0 +1,31 @@
package com.deepdata.datamanagement.infrastructure.persistence.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.deepdata.common.infrastructure.config.IgnoreDataScopeAnnotation;
import com.deepdata.datamanagement.domain.model.dataset.DatasetFile;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.session.RowBounds;
import java.util.List;
@Mapper
@IgnoreDataScopeAnnotation
public interface DatasetFileMapper extends BaseMapper<DatasetFile> {
DatasetFile findById(@Param("id") String id);
List<DatasetFile> findByDatasetId(@Param("datasetId") String datasetId, RowBounds rowBounds);
List<DatasetFile> findByDatasetIdAndStatus(@Param("datasetId") String datasetId, @Param("status") String status, RowBounds rowBounds);
List<DatasetFile> findByDatasetIdAndFileType(@Param("datasetId") String datasetId, @Param("fileType") String fileType, RowBounds rowBounds);
Long countByDatasetId(@Param("datasetId") String datasetId);
Long countCompletedByDatasetId(@Param("datasetId") String datasetId);
Long sumSizeByDatasetId(@Param("datasetId") String datasetId);
DatasetFile findByDatasetIdAndFileName(@Param("datasetId") String datasetId, @Param("fileName") String fileName);
List<DatasetFile> findAllByDatasetId(@Param("datasetId") String datasetId);
List<DatasetFile> findByCriteria(@Param("datasetId") String datasetId,
@Param("fileType") String fileType,
@Param("status") String status,
RowBounds rowBounds);
int update(DatasetFile file);
int deleteById(@Param("id") String id);
}
@@ -0,0 +1,40 @@
package com.deepdata.datamanagement.infrastructure.persistence.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.deepdata.datamanagement.domain.model.dataset.Dataset;
import com.deepdata.datamanagement.interfaces.dto.AllDatasetStatisticsResponse;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.session.RowBounds;
import java.util.List;
@Mapper
public interface DatasetMapper extends BaseMapper<Dataset> {
Dataset findById(@Param("id") String id);
/**
* 使用悲观锁查询数据集(FOR UPDATE)
*/
@Select("SELECT * FROM t_dm_datasets WHERE id = #{id} FOR UPDATE")
Dataset findByIdWithLock(@Param("id") String id);
Dataset findByName(@Param("name") String name);
List<Dataset> findByStatus(@Param("status") String status);
List<Dataset> findByCreatedBy(@Param("createdBy") String createdBy, RowBounds rowBounds);
List<Dataset> findByTypeCode(@Param("typeCode") String typeCode, RowBounds rowBounds);
List<Dataset> findByTagNames(@Param("tagNames") List<String> tagNames, RowBounds rowBounds);
List<Dataset> findByKeyword(@Param("keyword") String keyword, RowBounds rowBounds);
List<Dataset> findByCriteria(@Param("typeCode") String typeCode,
@Param("status") String status,
@Param("keyword") String keyword,
@Param("tagNames") List<String> tagNames,
RowBounds rowBounds);
long countByCriteria(@Param("typeCode") String typeCode,
@Param("status") String status,
@Param("keyword") String keyword,
@Param("tagNames") List<String> tagNames);
int deleteById(@Param("id") String id);
AllDatasetStatisticsResponse getAllDatasetStatistics();
}
@@ -0,0 +1,27 @@
package com.deepdata.datamanagement.infrastructure.persistence.mapper;
import com.deepdata.datamanagement.domain.model.dataset.Tag;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface TagMapper {
Tag findById(@Param("id") String id);
Tag findByName(@Param("name") String name);
List<Tag> findByNameIn(@Param("list") List<String> names);
List<Tag> findByIdIn(@Param("ids") List<String> ids);
List<Tag> findByKeyword(@Param("keyword") String keyword);
List<Tag> findAllByOrderByUsageCountDesc();
int insert(Tag tag);
int update(Tag tag);
int updateUsageCount(@Param("id") String id, @Param("usageCount") Long usageCount);
// Relations with dataset
int insertDatasetTag(@Param("datasetId") String datasetId, @Param("tagId") String tagId);
int deleteDatasetTagsByDatasetId(@Param("datasetId") String datasetId);
List<Tag> findByDatasetId(@Param("datasetId") String datasetId);
void deleteTagsById(@Param("ids") List<String> ids);
}
@@ -0,0 +1,28 @@
package com.deepdata.datamanagement.infrastructure.persistence.repository;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import com.deepdata.datamanagement.domain.model.dataset.DatasetFile;
import java.util.List;
/**
* 数据集文件仓储接口
*
* @author dallas
* @since 2025-10-15
*/
public interface DatasetFileRepository extends IRepository<DatasetFile> {
Long countByDatasetId(String datasetId);
Long countCompletedByDatasetId(String datasetId);
Long sumSizeByDatasetId(String datasetId);
List<DatasetFile> findAllByDatasetId(String datasetId);
DatasetFile findByDatasetIdAndFileName(String datasetId, String fileName);
IPage<DatasetFile> findByCriteria(String datasetId, String fileType, String status, String name,
IPage<DatasetFile> page);
}
@@ -0,0 +1,34 @@
package com.deepdata.datamanagement.infrastructure.persistence.repository;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.IRepository;
import com.deepdata.datamanagement.domain.model.dataset.Dataset;
import com.deepdata.datamanagement.interfaces.dto.AllDatasetStatisticsResponse;
import com.deepdata.datamanagement.interfaces.dto.DatasetPagingQuery;
import org.apache.ibatis.session.RowBounds;
import java.util.List;
/**
* 数据集仓储层
*
* @author dallas
* @since 2025-10-15
*/
public interface DatasetRepository extends IRepository<Dataset> {
Dataset findByName(String name);
/**
* 使用悲观锁获取数据集(用于更新操作,防止并发冲突)
*/
Dataset getByIdWithLock(String id);
List<Dataset> findByCriteria(String type, String status, String keyword, List<String> tagList, RowBounds bounds);
long countByCriteria(String type, String status, String keyword, List<String> tagList);
AllDatasetStatisticsResponse getAllDatasetStatistics();
IPage<Dataset> findByCriteria(IPage<Dataset> page, DatasetPagingQuery query);
}
@@ -0,0 +1,59 @@
package com.deepdata.datamanagement.infrastructure.persistence.repository.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.CrudRepository;
import com.deepdata.datamanagement.domain.model.dataset.DatasetFile;
import com.deepdata.datamanagement.infrastructure.persistence.mapper.DatasetFileMapper;
import com.deepdata.datamanagement.infrastructure.persistence.repository.DatasetFileRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 数据集文件仓储实现类
*
* @author dallas
* @since 2025-10-15
*/
@Repository
@RequiredArgsConstructor
public class DatasetFileRepositoryImpl extends CrudRepository<DatasetFileMapper, DatasetFile> implements DatasetFileRepository {
private final DatasetFileMapper datasetFileMapper;
@Override
public Long countByDatasetId(String datasetId) {
return datasetFileMapper.selectCount(new LambdaQueryWrapper<DatasetFile>().eq(DatasetFile::getDatasetId, datasetId));
}
@Override
public Long countCompletedByDatasetId(String datasetId) {
return datasetFileMapper.countCompletedByDatasetId(datasetId);
}
@Override
public Long sumSizeByDatasetId(String datasetId) {
return datasetFileMapper.sumSizeByDatasetId(datasetId);
}
@Override
public List<DatasetFile> findAllByDatasetId(String datasetId) {
return datasetFileMapper.findAllByDatasetId(datasetId);
}
@Override
public DatasetFile findByDatasetIdAndFileName(String datasetId, String fileName) {
return datasetFileMapper.findByDatasetIdAndFileName(datasetId, fileName);
}
public IPage<DatasetFile> findByCriteria(String datasetId, String fileType, String status, String name,
IPage<DatasetFile> page) {
return datasetFileMapper.selectPage(page, new LambdaQueryWrapper<DatasetFile>()
.eq(DatasetFile::getDatasetId, datasetId)
.eq(StringUtils.hasText(fileType), DatasetFile::getFileType, fileType)
.eq(StringUtils.hasText(status), DatasetFile::getStatus, status)
.like(StringUtils.hasText(name), DatasetFile::getFileName, name));
}
}
@@ -0,0 +1,84 @@
package com.deepdata.datamanagement.infrastructure.persistence.repository.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.repository.CrudRepository;
import com.deepdata.datamanagement.domain.model.dataset.Dataset;
import com.deepdata.datamanagement.infrastructure.persistence.mapper.DatasetMapper;
import com.deepdata.datamanagement.infrastructure.persistence.repository.DatasetRepository;
import com.deepdata.datamanagement.interfaces.dto.AllDatasetStatisticsResponse;
import com.deepdata.datamanagement.interfaces.dto.DatasetPagingQuery;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.session.RowBounds;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 数据集仓储层实现类
*
* @author dallas
* @since 2025-10-15
*/
@Repository
@RequiredArgsConstructor
public class DatasetRepositoryImpl extends CrudRepository<DatasetMapper, Dataset> implements DatasetRepository {
private final DatasetMapper datasetMapper;
@Override
public Dataset findByName(String name) {
return datasetMapper.selectOne(new LambdaQueryWrapper<Dataset>().eq(Dataset::getName, name));
}
@Override
public Dataset getByIdWithLock(String id) {
return datasetMapper.findByIdWithLock(id);
}
@Override
public List<Dataset> findByCriteria(String type, String status, String keyword, List<String> tagList,
RowBounds bounds) {
return datasetMapper.findByCriteria(type, status, keyword, tagList, bounds);
}
@Override
public long countByCriteria(String type, String status, String keyword, List<String> tagList) {
return datasetMapper.countByCriteria(type, status, keyword, tagList);
}
@Override
public AllDatasetStatisticsResponse getAllDatasetStatistics() {
return datasetMapper.getAllDatasetStatistics();
}
@Override
public IPage<Dataset> findByCriteria(IPage<Dataset> page, DatasetPagingQuery query) {
LambdaQueryWrapper<Dataset> wrapper = new LambdaQueryWrapper<Dataset>()
.eq(query.getType() != null, Dataset::getDatasetType, query.getType())
.eq(query.getStatus() != null, Dataset::getStatus, query.getStatus());
if (StringUtils.isNotBlank(query.getKeyword())) {
wrapper.and(w ->
w.like(Dataset::getName, query.getKeyword()).or()
.like(Dataset::getDescription, query.getKeyword()));
}
/*
标签过滤 {@link Tag}
*/
for (String tagName : query.getTags()) {
wrapper.and(w ->
w.apply("EXISTS ( " +
"SELECT 1 FROM jsonb_array_elements( " +
"CASE WHEN jsonb_typeof(tags::jsonb) = 'array' THEN tags::jsonb ELSE '[]'::jsonb END " +
") AS tag " +
"WHERE tag->>'name' = {0} " +
")", tagName)
);
}
wrapper.orderByDesc(Dataset::getCreatedAt);
return datasetMapper.selectPage(page, wrapper);
}
}
@@ -0,0 +1,86 @@
package com.deepdata.datamanagement.interfaces.converter;
import com.deepdata.common.domain.model.ChunkUploadRequest;
import com.deepdata.datamanagement.domain.model.dataset.Dataset;
import com.deepdata.datamanagement.domain.model.dataset.DatasetFile;
import com.deepdata.datamanagement.domain.model.dataset.FileTag;
import com.deepdata.datamanagement.interfaces.dto.*;
import org.apache.commons.collections4.CollectionUtils;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import org.mapstruct.factory.Mappers;
import java.util.*;
/**
* 数据集文件转换器
*/
@Mapper
public interface DatasetConverter {
/** 单例实例 */
DatasetConverter INSTANCE = Mappers.getMapper(DatasetConverter.class);
/**
* 将数据集转换为响应
*/
@Mapping(source = "sizeBytes", target = "totalSize")
@Mapping(source = "path", target = "targetLocation")
@Mapping(source = "files", target = "distribution", qualifiedByName = "getDistribution")
DatasetResponse convertToResponse(Dataset dataset);
/**
* 将数据集转换为响应
*/
@Mapping(target = "tags", ignore = true)
Dataset convertToDataset(CreateDatasetRequest createDatasetRequest);
/**
* 将上传文件请求转换为分片上传请求
*/
ChunkUploadRequest toChunkUploadRequest(UploadFileRequest uploadFileRequest);
/**
* 将数据集转换为响应
*/
List<DatasetResponse> convertToResponse(List<Dataset> datasets);
/**
*
* 将数据集文件转换为响应
*/
DatasetFileResponse convertToResponse(DatasetFile datasetFile);
/**
* 将数据集文件列表转换为响应
*/
List<DatasetFileResponse> convertToResponseList(List<DatasetFile> datasetFiles);
/**
* 获取数据文件的标签分布
*
* @param datasetFiles 数据集文件
* @return 标签分布
*/
@Named("getDistribution")
default Map<String, Map<String, Long>> getDistribution(List<DatasetFile> datasetFiles) {
Map<String, Map<String, Long>> distribution = new HashMap<>();
if (CollectionUtils.isEmpty(datasetFiles)) {
return distribution;
}
for (DatasetFile datasetFile : datasetFiles) {
List<FileTag> tags = datasetFile.analyzeTag();
if (CollectionUtils.isEmpty(tags)) {
continue;
}
for (FileTag tag : tags) {
Map<String, Long> tagValueMap = distribution.getOrDefault(tag.getFromName(), new HashMap<>());
tag.getTagValues().forEach(tagValue -> tagValueMap.put(tagValue, tagValueMap.getOrDefault(tagValue, 0L) + 1));
distribution.put(tag.getFromName(), tagValueMap);
}
}
return distribution;
}
}
@@ -0,0 +1,30 @@
package com.deepdata.datamanagement.interfaces.converter;
import com.deepdata.datamanagement.domain.model.dataset.Tag;
import com.deepdata.datamanagement.interfaces.dto.TagResponse;
import com.deepdata.datamanagement.interfaces.dto.UpdateTagRequest;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/**
* 标签转换器
*/
@Mapper
public interface TagConverter {
/** 单例实例 */
TagConverter INSTANCE = Mappers.getMapper(TagConverter.class);
/**
* 将 UpdateTagRequest 转换为 Tag 实体
* @param request 更新标签请求DTO
* @return 标签实体
*/
Tag updateRequestToTag(UpdateTagRequest request);
/**
* 将 Tag 实体转换为 TagResponse DTO
* @param tag 标签实体
* @return 标签响应DTO
*/
TagResponse convertToResponse(Tag tag);
}
@@ -0,0 +1,68 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidPath;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.commons.collections4.CollectionUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* AddFilesRequest
*
* @since 2026/2/9
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AddFilesRequest {
private boolean softAdd;
@ValidPath
private String prefix = "";
@NotEmpty(message = "文件列表不能为空")
@Size(max = 1000, message = "文件数量不能超过1000个")
@Valid
private List<FileRequest> files;
public AddFilesRequest(List<String> paths) {
if (CollectionUtils.isEmpty(paths)) {
return;
}
this.files = paths.stream().map(path -> {
FileRequest file = new FileRequest();
file.setFilePath(path);
return file;
}).toList();
}
/**
* 获取文件的前缀,优先使用文件自身的 prefix,为空时回退到请求级别的 prefix
*/
public String getEffectivePrefix(FileRequest file) {
return (file.getPrefix() != null && !file.getPrefix().isEmpty()) ? file.getPrefix() : prefix;
}
@Getter
@Setter
public static class FileRequest {
@NotBlank(message = "文件路径不能为空")
@Size(max = 1000, message = "文件路径长度不能超过1000个字符")
private String filePath;
@ValidPath
private String prefix = "";
private Map<String, Object> metadata = new HashMap<>();
}
}
@@ -0,0 +1,20 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
/**
* 所有数据集统计信息响应DTO
*/
@Getter
@Setter
public class AllDatasetStatisticsResponse {
/** 总数据集数 */
private Integer totalDatasets = 0;
/** 总文件数 */
private Long totalSize = 0L;
/** 总大小(字节) */
private Long totalFiles = 0L;
}
@@ -0,0 +1,28 @@
package com.deepdata.datamanagement.interfaces.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 批量删除文件请求
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class BatchDeleteFilesRequest {
/**
* 要删除的文件ID列表
*/
@NotEmpty(message = "文件ID列表不能为空")
private List<String> fileIds;
/**
* 文件路径前缀(用于处理子目录中的文件)
*/
private String prefix = "";
}
@@ -0,0 +1,18 @@
package com.deepdata.datamanagement.interfaces.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* 复制文件请求DTO
*
* @author dallas
* @since 2025-11-13
*/
public record CopyFilesRequest(
@NotEmpty(message = "源文件路径列表不能为空")
@Size(max = 1000, message = "文件数量不能超过1000个")
List<String> sourcePaths) {
}
@@ -0,0 +1,53 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.common.enums.DatasetStatusType;
import com.deepdata.datamanagement.common.enums.DatasetType;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springaicommunity.mcp.annotation.McpToolParam;
import java.util.List;
/**
* 创建数据集请求DTO
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CreateDatasetRequest {
/** 数据集名称 */
@Size(min = 1, max = 255, message = "数据集名称长度不能超过255个字符")
@NotBlank(message = "数据集名称不能为空")
@McpToolParam(description = "数据集名称")
private String name;
/** 数据集描述 */
@Size(max = 500, message = "数据集描述长度不能超过500个字符")
@McpToolParam(description = "数据集描述", required = false)
private String description;
/** 数据集类型 */
@NotNull(message = "数据集类型不能为空")
@McpToolParam(description = "数据集类型,取值范围为TEXT/IMAGE/VIDEO/AUDIO/OTHER")
private DatasetType datasetType;
/** 标签列表 */
@Size(max = 20, message = "标签数量不能超过20个")
@McpToolParam(description = "标签列表", required = false)
private List<String> tags;
/** 数据源 */
@Size(max = 255, message = "数据源长度不能超过255个字符")
@McpToolParam(description = "数据源", required = false)
private String dataSource;
/** 保留天数 */
@Min(value = 0, message = "保留天数必须为非负整数")
@McpToolParam(description = "保留天数", required = false)
private Integer retentionDays;
/** 数据集状态 */
@McpToolParam(description = "数据集状态", required = false)
private DatasetStatusType status;
}
@@ -0,0 +1,26 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidFileName;
import com.deepdata.datamanagement.interfaces.validation.ValidPath;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 创建数据集子目录请求
*/
@Getter
@Setter
public class CreateDirectoryRequest {
/** 父级前缀路径,例如 "images/",为空表示数据集根目录 */
@ValidPath()
private String parentPrefix;
/** 新建目录名称 */
@NotBlank(message = "目录名称不能为空")
@ValidFileName
@Size(max = 255, message = "目录名称长度不能超过255个字符")
private String directoryName;
}
@@ -0,0 +1,28 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidHexColor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 创建标签请求DTO
*/
@Getter
@Setter
public class CreateTagRequest {
/** 标签名称 */
@NotBlank(message = "标签名称不能为空")
@Size(min = 1, max = 100, message = "标签名称长度不能超过100个字符")
private String name;
/** 标签颜色 */
@ValidHexColor
private String color;
/** 标签描述 */
@Size(max = 500, message = "标签描述长度不能超过500个字符")
private String description;
/** 标签分类 */
@Size(max = 50, message = "标签分类长度不能超过50个字符")
private String category;
}
@@ -0,0 +1,46 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 数据集文件响应DTO
*/
@Getter
@Setter
public class DatasetFileResponse {
/** 文件ID */
private String id;
/** 文件名 */
private String fileName;
/** 原始文件名 */
private String originalName;
/** 文件类型 */
private String fileType;
/** 文件大小(字节) */
private Long fileSize;
/** 文件状态 */
private String status;
/** 文件描述 */
private String description;
/** 文件路径 */
private String filePath;
/** 文件标签(JSON 字符串) */
private String tags;
/** 标签更新时间 */
private LocalDateTime tagsUpdatedAt;
/** 文件元数据(包含标注信息等,JSON 字符串) */
private String metadata;
/** 上传时间 */
private LocalDateTime uploadTime;
/** 最后更新时间 */
private LocalDateTime lastAccessTime;
/** 上传者 */
private String uploadedBy;
/** 是否为目录 */
private Boolean directory;
/** 目录文件数量 */
private Long fileCount;
}
@@ -0,0 +1,30 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.common.domain.model.LineageEdge;
import com.deepdata.common.domain.model.LineageNode;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.List;
/**
* 数据集血缘
*
* @since 2026/1/23
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DatasetLineage {
/**
* 节点列表
*/
private List<LineageNode> lineageNodes;
/**
* 边列表
*/
private List<LineageEdge> lineageEdges;
}
@@ -0,0 +1,50 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.common.interfaces.PagingQuery;
import com.deepdata.datamanagement.common.enums.DatasetStatusType;
import com.deepdata.datamanagement.common.enums.DatasetType;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springaicommunity.mcp.annotation.McpToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* 数据集分页查询请求
*
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DatasetPagingQuery extends PagingQuery {
/**
* 数据集类型过滤
*/
@McpToolParam(description = "数据集类型过滤,取值范围为text/image/video/audio/other", required = false)
private DatasetType type;
/**
* 标签名过滤
*/
@Size(max = 10, message = "过滤标签数量不能超过10个")
@McpToolParam(description = "标签名过滤", required = false)
private List<String> tags = new ArrayList<>();
/**
* 关键词搜索(名称或描述)
*/
@Size(max = 100, message = "关键词长度不能超过100个字符")
@McpToolParam(description = "关键词搜索(名称或描述)", required = false)
private String keyword;
/**
* 状态过滤
*/
@McpToolParam(description = "状态过滤", required = false)
private DatasetStatusType status;
}
@@ -0,0 +1,52 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 数据集响应DTO
*/
@Getter
@Setter
public class DatasetResponse {
/** 数据集ID */
private String id;
/** 数据集名称 */
private String name;
/** 数据集描述 */
private String description;
/** 数据集类型 */
private String datasetType;
/** 数据集状态 */
private String status;
/** 标签列表 */
private List<TagResponse> tags;
/** 数据集保留天数 */
private Integer retentionDays;
/** 数据源 */
private String dataSource;
/** 目标位置 */
private String targetLocation;
/** 文件数量 */
private Integer fileCount;
/** 总大小(字节) */
private Long totalSize;
/** 完成率(0-100) */
private Float completionRate;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 创建者 */
private String createdBy;
/** 更新者 */
private String updatedBy;
/** 分布 */
private Map<String, Map<String, Long>> distribution;
/** 数据集pvc名称 */
private String pvcName;
}
@@ -0,0 +1,26 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.Map;
/**
* 数据集统计信息响应DTO
*/
@Getter
@Setter
public class DatasetStatisticsResponse {
/** 总文件数 */
private Integer totalFiles;
/** 已完成文件数 */
private Integer completedFiles;
/** 总大小(字节) */
private Long totalSize;
/** 完成率(0-100) */
private Float completionRate;
/** 文件类型分布 */
private Map<String, Integer> fileTypeDistribution;
/** 状态分布 */
private Map<String, Integer> statusDistribution;
}
@@ -0,0 +1,24 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 数据集类型响应DTO
*/
@Getter
@Setter
public class DatasetTypeResponse {
/** 类型编码 */
private String code;
/** 类型名称 */
private String name;
/** 类型描述 */
private String description;
/** 支持的文件格式 */
private List<String> supportedFormats;
/** 图标 */
private String icon;
}
@@ -0,0 +1,28 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 数据集文件分页响应DTO
*/
@Getter
@Setter
public class PagedDatasetFileResponse {
/** 文件内容列表 */
private List<DatasetFileResponse> content;
/** 当前页码 */
private Integer page;
/** 每页大小 */
private Integer size;
/** 总元素数 */
private Integer totalElements;
/** 总页数 */
private Integer totalPages;
/** 是否为第一页 */
private Boolean first;
/** 是否为最后一页 */
private Boolean last;
}
@@ -0,0 +1,28 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 数据集分页响应DTO
*/
@Getter
@Setter
public class PagedDatasetResponse {
/** 数据集内容列表 */
private List<DatasetResponse> content;
/** 当前页码 */
private Integer page;
/** 每页大小 */
private Integer size;
/** 总元素数 */
private Integer totalElements;
/** 总页数 */
private Integer totalPages;
/** 是否为第一页 */
private Boolean first;
/** 是否为最后一页 */
private Boolean last;
}
@@ -0,0 +1,27 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidFileName;
import com.deepdata.datamanagement.interfaces.validation.ValidPath;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 重命名数据集目录请求
*/
@Getter
@Setter
public class RenameDirectoryRequest {
/** 目录前缀,例如 "images/",与列表/删除目录接口保持一致 */
@NotBlank(message = "目录前缀不能为空")
@ValidPath(maxLength = 500)
private String prefix;
/** 新的目录名称 */
@NotBlank(message = "新目录名称不能为空")
@ValidFileName
@Size(max = 255, message = "目录名称长度不能超过255个字符")
private String newName;
}
@@ -0,0 +1,21 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidFileName;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 重命名数据集文件请求
*/
@Getter
@Setter
public class RenameFileRequest {
/** 新的文件名称(不包含后缀) */
@NotBlank(message = "新文件名不能为空")
@ValidFileName
@Size(max = 255, message = "文件名称长度不能超过255个字符")
private String newName;
}
@@ -0,0 +1,22 @@
package com.deepdata.datamanagement.interfaces.dto;
import lombok.Getter;
import lombok.Setter;
/**
* 标签响应DTO
*/
@Getter
@Setter
public class TagResponse {
/** 标签ID */
private String id;
/** 标签名称 */
private String name;
/** 标签颜色 */
private String color;
/** 标签描述 */
private String description;
/** 使用次数 */
private Integer usageCount;
}
@@ -0,0 +1,32 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.common.enums.DatasetStatusType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 更新数据集请求DTO
*/
@Getter
@Setter
public class UpdateDatasetRequest {
/** 数据集名称 */
@Size(min = 1, max = 255, message = "数据集名称长度不能超过255个字符")
@NotBlank(message = "数据集名称不能为空")
private String name;
/** 数据集描述 */
@Size(max = 500, message = "数据集描述长度不能超过500个字符")
private String description;
/** 归集任务id */
@Size(max = 255, message = "数据源长度不能超过255个字符")
private String dataSource;
/** 标签列表 */
@Size(max = 20, message = "标签数量不能超过20个")
private List<String> tags;
/** 数据集状态 */
private DatasetStatusType status;
}
@@ -0,0 +1,27 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidHexColor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 更新标签请求DTO
*/
@Getter
@Setter
public class UpdateTagRequest {
/** 标签 ID */
@NotBlank(message = "标签ID不能为空")
private String id;
/** 标签名称 */
@Size(max = 100, message = "标签名称长度不能超过100个字符")
private String name;
/** 标签颜色 */
@ValidHexColor
private String color;
/** 标签描述 */
@Size(max = 500, message = "标签描述长度不能超过500个字符")
private String description;
}
@@ -0,0 +1,49 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidFilePath;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import org.springframework.web.multipart.MultipartFile;
/**
* 上传文件请求
* 用于分块上传文件时的请求参数封装,支持大文件分片上传功能
*/
@Getter
@Setter
public class UploadFileRequest {
/** 预上传返回的id,用来确认同一个任务 */
@NotBlank(message = "请求ID不能为空")
private String reqId;
/** 文件编号,用于标识批量上传中的第几个文件 */
@Min(value = 0, message = "文件编号必须为非负整数")
private int fileNo;
/** 文件名称(支持相对路径,用于文件夹上传) */
@NotBlank(message = "文件名称不能为空")
@ValidFilePath
@Size(max = 255, message = "文件名称长度不能超过255个字符")
private String fileName;
/** 文件总分块数量 */
@Min(value = 1, message = "总分块数量必须大于0")
private int totalChunkNum;
/** 当前分块编号,从1开始 */
@Min(value = 1, message = "分块编号必须大于0")
private int chunkNo;
/** 上传的文件分块内容 */
@NotNull(message = "文件内容不能为空")
private MultipartFile file;
/** 文件分块的校验和(十六进制字符串),用于验证文件完整性 */
@Pattern(regexp = "^[0-9a-fA-F]{64}$", message = "校验和格式不正确,应为64位十六进制字符串")
private String checkSumHex;
}
@@ -0,0 +1,28 @@
package com.deepdata.datamanagement.interfaces.dto;
import com.deepdata.datamanagement.interfaces.validation.ValidPath;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
/**
* 切片上传预上传请求
*/
@Getter
@Setter
public class UploadFilesPreRequest {
/** 是否为压缩包上传 */
private boolean hasArchive;
/** 总文件数量 */
@Min(value = 1, message = "总文件数量必须大于0")
private int totalFileNum;
/** 总文件大小 */
@Min(value = 1, message = "总文件大小必须大于0")
private long totalSize;
/** 目标子目录前缀,例如 "images/",为空表示数据集根目录 */
@ValidPath(maxLength = 500)
private String prefix;
}
@@ -0,0 +1,141 @@
package com.deepdata.datamanagement.interfaces.rest;
import com.deepdata.datamanagement.interfaces.dto.*;
import com.deepdata.common.infrastructure.common.Response;
import com.deepdata.common.infrastructure.exception.SystemErrorCode;
import com.deepdata.common.interfaces.PagedResponse;
import com.deepdata.datamanagement.application.DatasetApplicationService;
import com.deepdata.datamanagement.domain.model.dataset.Dataset;
import com.deepdata.datamanagement.interfaces.converter.DatasetConverter;
import com.deepdata.datamanagement.interfaces.dto.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 数据集 REST 控制器
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/data-management/datasets")
@Validated
public class DatasetController {
private final DatasetApplicationService datasetApplicationService;
/**
* 获取数据集列表
*
* @param query 分页查询参数
* @return 分页的数据集列表
*/
@GetMapping
@McpTool(name = "query_datasets", description = "根据参数查询满足条件的数据集列表")
public PagedResponse<DatasetResponse> getDatasets(DatasetPagingQuery query) {
return datasetApplicationService.getDatasets(query);
}
/**
* 创建数据集
*
* @param createDatasetRequest 创建数据集请求参数
* @return 创建的数据集响应
*/
@PostMapping
@McpTool(name = "create_dataset", description = "创建数据集")
public DatasetResponse createDataset(@RequestBody @Valid CreateDatasetRequest createDatasetRequest) {
Dataset dataset = datasetApplicationService.createDataset(createDatasetRequest);
return DatasetConverter.INSTANCE.convertToResponse(dataset);
}
/**
* 根据ID获取数据集详情
*
* @param datasetId 数据集ID
* @return 数据集响应
*/
@GetMapping("/{datasetId}")
public DatasetResponse getDatasetById(
@PathVariable("datasetId")
@Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "数据集ID格式不正确,应为UUID格式")
String datasetId) {
DatasetResponse dataset = DatasetConverter.INSTANCE.convertToResponse(datasetApplicationService.getDataset(datasetId));
dataset.setPvcName(datasetApplicationService.getDatasetPvcName());
return dataset;
}
/**
* 根据ID更新数据集
*
* @param datasetId 数据集ID
* @param updateDatasetRequest 更新数据集请求参数
* @return 更新后的数据集响应
*/
@PutMapping("/{datasetId}")
public DatasetResponse updateDataset(
@PathVariable("datasetId")
@Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "数据集ID格式不正确,应为UUID格式")
String datasetId,
@RequestBody UpdateDatasetRequest updateDatasetRequest) {
Dataset dataset = datasetApplicationService.updateDataset(datasetId, updateDatasetRequest);
return DatasetConverter.INSTANCE.convertToResponse(dataset);
}
@GetMapping("/{datasetId}/lineage")
public DatasetLineage getDatasetLineage(
@PathVariable("datasetId")
@Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "数据集ID格式不正确,应为UUID格式")
String datasetId) {
return datasetApplicationService.getDatasetLineage(datasetId);
}
/**
* 根据ID删除数据集
*
* @param datasetId 数据集ID
*/
@DeleteMapping("/{datasetId}")
public void deleteDataset(
@PathVariable("datasetId")
@Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "数据集ID格式不正确,应为UUID格式")
String datasetId) {
datasetApplicationService.deleteDataset(datasetId);
}
@GetMapping("/{datasetId}/statistics")
public ResponseEntity<Response<DatasetStatisticsResponse>> getDatasetStatistics(
@PathVariable("datasetId")
@Pattern(regexp = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", message = "数据集ID格式不正确,应为UUID格式")
String datasetId) {
try {
Map<String, Object> stats = datasetApplicationService.getDatasetStatistics(datasetId);
DatasetStatisticsResponse response = new DatasetStatisticsResponse();
response.setTotalFiles((Integer) stats.get("totalFiles"));
response.setCompletedFiles((Integer) stats.get("completedFiles"));
response.setTotalSize((Long) stats.get("totalSize"));
response.setCompletionRate((Float) stats.get("completionRate"));
response.setFileTypeDistribution((Map<String, Integer>) stats.get("fileTypeDistribution"));
response.setStatusDistribution((Map<String, Integer>) stats.get("statusDistribution"));
return ResponseEntity.ok(Response.ok(response));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Response.error(SystemErrorCode.UNKNOWN_ERROR, null));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Response.error(SystemErrorCode.UNKNOWN_ERROR, null));
}
}
@GetMapping("/statistics")
public ResponseEntity<Response<AllDatasetStatisticsResponse>> getAllStatistics() {
return ResponseEntity.ok(Response.ok(datasetApplicationService.getAllDatasetStatistics()));
}
}
@@ -0,0 +1,245 @@
package com.deepdata.datamanagement.interfaces.rest;
import com.deepdata.common.infrastructure.common.IgnoreResponseWrap;
import com.deepdata.common.infrastructure.common.Response;
import com.deepdata.common.infrastructure.exception.SystemErrorCode;
import com.deepdata.common.interfaces.PagedResponse;
import com.deepdata.common.interfaces.PagingQuery;
import com.deepdata.datamanagement.application.DatasetApplicationService;
import com.deepdata.datamanagement.application.DatasetFileApplicationService;
import com.deepdata.datamanagement.domain.model.dataset.Dataset;
import com.deepdata.datamanagement.domain.model.dataset.DatasetFile;
import com.deepdata.datamanagement.interfaces.converter.DatasetConverter;
import com.deepdata.datamanagement.interfaces.dto.AddFilesRequest;
import com.deepdata.datamanagement.interfaces.dto.BatchDeleteFilesRequest;
import com.deepdata.datamanagement.interfaces.dto.CopyFilesRequest;
import com.deepdata.datamanagement.interfaces.dto.CreateDirectoryRequest;
import com.deepdata.datamanagement.interfaces.dto.DatasetFileResponse;
import com.deepdata.datamanagement.interfaces.dto.UploadFileRequest;
import com.deepdata.datamanagement.interfaces.dto.UploadFilesPreRequest;
import com.deepdata.datamanagement.interfaces.dto.RenameFileRequest;
import com.deepdata.datamanagement.interfaces.dto.RenameDirectoryRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 数据集文件 REST 控制器(UUID 模式)
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/data-management/datasets/{datasetId}/files")
public class DatasetFileController {
private final DatasetFileApplicationService datasetFileApplicationService;
private final DatasetApplicationService datasetApplicationService;
@GetMapping
public Response<PagedResponse<DatasetFile>> getDatasetFiles(
@PathVariable("datasetId") String datasetId,
@RequestParam(value = "isWithDirectory", required = false) boolean isWithDirectory,
@RequestParam(value = "page", required = false, defaultValue = "0") Integer page,
@RequestParam(value = "size", required = false, defaultValue = "20") Integer size,
@RequestParam(value = "prefix", required = false, defaultValue = "") String prefix) {
PagingQuery pagingQuery = new PagingQuery(page, size);
PagedResponse<DatasetFile> filesPage;
if (isWithDirectory) {
filesPage = datasetFileApplicationService.getDatasetFilesWithDirectory(datasetId, prefix, pagingQuery);
} else {
filesPage = datasetFileApplicationService.getDatasetFiles(datasetId, null, null, null, pagingQuery);
}
return Response.ok(filesPage);
}
@GetMapping("/{fileId}")
public ResponseEntity<Response<DatasetFileResponse>> getDatasetFileById(
@PathVariable("datasetId") String datasetId,
@PathVariable("fileId") String fileId,
@RequestParam(value = "prefix", required = false, defaultValue = "") String prefix) {
try {
Dataset dataset = datasetApplicationService.getDataset(datasetId);
DatasetFile datasetFile = datasetFileApplicationService.getDatasetFile(dataset, fileId, prefix);
return ResponseEntity.ok(Response.ok(DatasetConverter.INSTANCE.convertToResponse(datasetFile)));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Response.error(SystemErrorCode.UNKNOWN_ERROR, null));
}
}
@DeleteMapping("/{fileId}")
public ResponseEntity<Response<Void>> deleteDatasetFile(
@PathVariable("datasetId") String datasetId,
@PathVariable("fileId") String fileId,
@RequestParam(value = "prefix", required = false, defaultValue = "") String prefix) {
try {
datasetFileApplicationService.deleteDatasetFile(datasetId, fileId, prefix);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Response.error(SystemErrorCode.UNKNOWN_ERROR, null));
}
}
/**
* 批量删除文件
*/
@DeleteMapping("/batch")
public ResponseEntity<Response<Void>> batchDeleteFiles(
@PathVariable("datasetId") String datasetId,
@RequestBody @Valid BatchDeleteFilesRequest request) {
try {
datasetFileApplicationService.batchDeleteFiles(datasetId, request);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Response.error(SystemErrorCode.UNKNOWN_ERROR, null));
}
}
@IgnoreResponseWrap
@GetMapping(value = "/{fileId}/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE + ";charset=UTF-8")
public ResponseEntity<Resource> downloadDatasetFileById(@PathVariable("datasetId") String datasetId,
@PathVariable("fileId") String fileId,
@RequestParam(value = "prefix", required = false, defaultValue = "") String prefix) {
try {
log.info("downloadDatasetFileById datasetId:{}, fileId:{}, prefix:{}", datasetId, fileId, prefix);
Dataset dataset = datasetApplicationService.getDataset(datasetId);
DatasetFile datasetFile = datasetFileApplicationService.getDatasetFile(dataset, fileId, prefix);
Resource resource = datasetFileApplicationService.downloadFile(datasetFile);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + datasetFile.getFileName() + "\"")
.body(resource);
} catch (IllegalArgumentException e) {
log.error("downloadDatasetFileById error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
} catch (Exception e) {
log.error("downloadDatasetFileById error: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@IgnoreResponseWrap
@GetMapping(value = "/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void downloadDatasetFileAsZip(@PathVariable("datasetId") String datasetId, HttpServletResponse response) {
datasetFileApplicationService.downloadDatasetFileAsZip(datasetId, response);
}
/**
* 文件上传请求
*
* @param request 批量文件上传请求
* @return 批量上传请求id
*/
@PostMapping("/upload/pre-upload")
public ResponseEntity<Response<String>> preUpload(@PathVariable("datasetId") String datasetId,
@RequestBody @Valid UploadFilesPreRequest request) {
return ResponseEntity.ok(Response.ok(datasetFileApplicationService.preUpload(request, datasetId)));
}
/**
* 分块上传
*
* @param uploadFileRequest 上传文件请求
*/
@PostMapping("/upload/chunk")
public ResponseEntity<Void> chunkUpload(@PathVariable("datasetId") String datasetId,
@Valid UploadFileRequest uploadFileRequest) {
log.info("file upload reqId:{}, fileNo:{}, total chunk num:{}, current chunkNo:{}",
uploadFileRequest.getReqId(), uploadFileRequest.getFileNo(), uploadFileRequest.getTotalChunkNum(),
uploadFileRequest.getChunkNo());
datasetFileApplicationService.chunkUpload(datasetId, uploadFileRequest);
return ResponseEntity.ok().build();
}
/**
* 将指定路径中的文件拷贝到数据集目录下
*
* @param datasetId 数据集ID
* @param req 源文件路径列表
* @return 数据集文件响应DTO列表
*/
@PostMapping("/upload/copy")
public List<DatasetFileResponse> copyFilesToDatasetDir(@PathVariable("datasetId") String datasetId,
@RequestBody @Valid CopyFilesRequest req) {
AddFilesRequest addFilesRequest = new AddFilesRequest(req.sourcePaths());
List<DatasetFile> datasetFiles = datasetFileApplicationService.addFilesToDataset(datasetId, addFilesRequest);
return DatasetConverter.INSTANCE.convertToResponseList(datasetFiles);
}
/**
* 添加文件到数据集(仅创建数据库记录,不执行文件系统操作)
*
* @param datasetId 数据集ID
* @param req 添加文件请求(包含源文件路径列表和softAdd标志)
* @return 数据集文件响应DTO列表
*/
@PostMapping("/upload/add")
public List<DatasetFileResponse> addFilesToDataset(@PathVariable("datasetId") String datasetId,
@RequestBody @Valid AddFilesRequest req) {
List<DatasetFile> datasetFiles = datasetFileApplicationService.addFilesToDataset(datasetId, req);
return DatasetConverter.INSTANCE.convertToResponseList(datasetFiles);
}
/**
* 在数据集下创建子目录
*/
@PostMapping("/directories")
public ResponseEntity<Void> createDirectory(@PathVariable("datasetId") String datasetId,
@RequestBody @Valid CreateDirectoryRequest req) {
datasetFileApplicationService.createDirectory(datasetId, req);
return ResponseEntity.ok().build();
}
/**
* 下载目录(压缩为 ZIP)
*/
@IgnoreResponseWrap
@GetMapping(value = "/directories/download", produces = "application/zip")
public void downloadDirectory(@PathVariable("datasetId") String datasetId,
@RequestParam(value = "prefix", required = false, defaultValue = "") String prefix,
HttpServletResponse response) {
datasetFileApplicationService.downloadDirectory(datasetId, prefix, response);
}
/**
* 删除目录及其所有内容
*/
@DeleteMapping("/directories")
public ResponseEntity<Void> deleteDirectory(@PathVariable("datasetId") String datasetId,
@RequestParam(value = "prefix", required = false, defaultValue = "") String prefix) {
datasetFileApplicationService.deleteDirectory(datasetId, prefix);
return ResponseEntity.ok().build();
}
/**
* 重命名文件
*/
@PutMapping("/{fileId}/rename")
public ResponseEntity<Void> renameFile(@PathVariable("datasetId") String datasetId,
@PathVariable("fileId") String fileId,
@RequestBody @Valid RenameFileRequest request) {
datasetFileApplicationService.renameFile(datasetId, fileId, request);
return ResponseEntity.ok().build();
}
/**
* 重命名目录
*/
@PutMapping("/directories/rename")
public ResponseEntity<Void> renameDirectory(@PathVariable("datasetId") String datasetId,
@RequestBody @Valid RenameDirectoryRequest request) {
datasetFileApplicationService.renameDirectory(datasetId, request);
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,53 @@
package com.deepdata.datamanagement.interfaces.rest;
import com.deepdata.datamanagement.interfaces.dto.DatasetTypeResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
/**
* 数据集类型 REST 控制器
*/
@RestController
@RequestMapping("/data-management/dataset-types")
public class DatasetTypeController {
/**
* 获取所有支持的数据集类型
* @return 数据集类型列表
*/
@GetMapping
public List<DatasetTypeResponse> getDatasetTypes() {
return Arrays.asList(
createDatasetType("IMAGE", "图像数据集", "用于机器学习的图像数据集", Arrays.asList("jpg", "jpeg", "png", "bmp", "gif")),
createDatasetType("TEXT", "文本数据集", "用于文本分析的文本数据集", Arrays.asList("txt", "csv", "json", "xml")),
createDatasetType("AUDIO", "音频数据集", "用于音频处理的音频数据集", Arrays.asList("wav", "mp3", "flac", "aac")),
createDatasetType("VIDEO", "视频数据集", "用于视频分析的视频数据集", Arrays.asList("mp4", "avi", "mov", "mkv")),
createDatasetType("MULTIMODAL", "多模态数据集", "包含多种数据类型的数据集", List.of("*"))
);
}
private DatasetTypeResponse createDatasetType(String code, String name, String description, List<String> supportedFormats) {
DatasetTypeResponse response = new DatasetTypeResponse();
response.setCode(code);
response.setName(name);
response.setDescription(description);
response.setSupportedFormats(supportedFormats);
response.setIcon(getIconForType(code));
return response;
}
private String getIconForType(String typeCode) {
return switch (typeCode) {
case "IMAGE" -> "🖼️";
case "TEXT" -> "📄";
case "AUDIO" -> "🎵";
case "VIDEO" -> "🎬";
case "MULTIMODAL" -> "📊";
default -> "📁";
};
}
}
@@ -0,0 +1,85 @@
package com.deepdata.datamanagement.interfaces.rest;
import com.deepdata.common.infrastructure.common.Response;
import com.deepdata.common.infrastructure.exception.SystemErrorCode;
import com.deepdata.datamanagement.application.TagApplicationService;
import com.deepdata.datamanagement.domain.model.dataset.Tag;
import com.deepdata.datamanagement.interfaces.converter.TagConverter;
import com.deepdata.datamanagement.interfaces.dto.CreateTagRequest;
import com.deepdata.datamanagement.interfaces.dto.TagResponse;
import com.deepdata.datamanagement.interfaces.dto.UpdateTagRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* 标签 REST 控制器(UUID 模式)
*/
@RestController
@RequestMapping("/data-management/tags")
public class TagController {
private final TagApplicationService tagApplicationService;
@Autowired
public TagController(TagApplicationService tagApplicationService) {
this.tagApplicationService = tagApplicationService;
}
/**
* 查询标签列表
*/
@GetMapping
public ResponseEntity<Response<List<TagResponse>>> getTags(@RequestParam(name = "keyword", required = false) String keyword) {
List<Tag> tags = tagApplicationService.searchTags(keyword);
List<TagResponse> response = tags.stream()
.map(TagConverter.INSTANCE::convertToResponse)
.collect(Collectors.toList());
return ResponseEntity.ok(Response.ok(response));
}
/**
* 创建标签
*/
@PostMapping
public ResponseEntity<Response<TagResponse>> createTag(@RequestBody CreateTagRequest createTagRequest) {
try {
Tag tag = tagApplicationService.createTag(
createTagRequest.getName(),
createTagRequest.getColor(),
createTagRequest.getDescription()
);
return ResponseEntity.ok(Response.ok(TagConverter.INSTANCE.convertToResponse(tag)));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Response.error(SystemErrorCode.UNKNOWN_ERROR, null));
}
}
/**
* 更新标签
*
* @param updateTagRequest 更新参数
* @return 更新结果
*/
@PutMapping
public ResponseEntity<Response<TagResponse>> updateTag(@RequestBody @Valid UpdateTagRequest updateTagRequest) {
Tag tag = tagApplicationService.updateTag(TagConverter.INSTANCE.updateRequestToTag(updateTagRequest));
return ResponseEntity.ok(Response.ok(TagConverter.INSTANCE.convertToResponse(tag)));
}
@DeleteMapping
public ResponseEntity<Response<Void>> deleteTag(@RequestParam(value = "ids") @Valid @Size(max = 10) List<String> ids) {
try {
tagApplicationService.deleteTag(ids.stream().filter(StringUtils::isNoneBlank).distinct().toList());
return ResponseEntity.ok(Response.ok(null));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Response.error(SystemErrorCode.UNKNOWN_ERROR, null));
}
}
}
@@ -0,0 +1,65 @@
package com.deepdata.datamanagement.interfaces.scheduler;
import com.deepdata.common.interfaces.PagedResponse;
import com.deepdata.datamanagement.application.DatasetApplicationService;
import com.deepdata.datamanagement.interfaces.dto.DatasetPagingQuery;
import com.deepdata.datamanagement.interfaces.dto.DatasetResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 数据集定时任务触发
*
* @since 2025/10/24
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DatasetScheduler {
private final DatasetApplicationService datasetApplicationService;
/**
* 每天凌晨 00:00 扫描并删除超出保留期的数据集
*/
@Scheduled(cron = "0 0 0 * * ?")
public void cleanupExpiredDatasets() {
int pageNo = 1;
int pageSize = 500;
while (true) {
DatasetPagingQuery datasetPagingQuery = new DatasetPagingQuery();
datasetPagingQuery.setPage(pageNo);
datasetPagingQuery.setSize(pageSize);
PagedResponse<DatasetResponse> datasets = datasetApplicationService.getDatasets(datasetPagingQuery);
if (CollectionUtils.isEmpty(datasets.getContent())) {
break;
}
datasets.getContent().forEach(dataset -> {
Integer retentionDays = dataset.getRetentionDays();
LocalDateTime createdAt = dataset.getCreatedAt();
if (retentionDays != null && retentionDays > 0 && createdAt != null) {
LocalDateTime expireAt = createdAt.plusDays(retentionDays);
if (expireAt.isBefore(LocalDateTime.now())) {
try {
log.info("Deleting dataset {}, expired at {} (retentionDays={})", dataset.getId(), expireAt, retentionDays);
datasetApplicationService.deleteDataset(dataset.getId());
} catch (Exception e) {
log.warn("Failed to delete expired dataset {}: {}", dataset.getId(), e.getMessage());
}
}
}
});
if (datasets.getPage() >= datasets.getTotalPages()) {
break;
}
pageNo++;
}
}
}
@@ -0,0 +1,26 @@
package com.deepdata.datamanagement.interfaces.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 文件名校验注解
* 验证文件名不包含非法字符
*
* @author DeepData
* @since 2026/02/11
*/
@Documented
@Constraint(validatedBy = ValidFileNameValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidFileName {
String message() default "文件名包含非法字符";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@@ -0,0 +1,43 @@
package com.deepdata.datamanagement.interfaces.validation;
import com.deepdata.datamanagement.infrastructure.exception.DataManagementErrorCode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
/**
* 文件名校验器
*
* @author DeepData
* @since 2026/02/11
*/
public class ValidFileNameValidator implements ConstraintValidator<ValidFileName, String> {
/**
* 文件名正则表达式
* 不允许包含特殊字符: / \ : * ? " < > | \0
* 允许字母、数字、中文、常见符号(- _ . space)
*/
private static final Pattern FILE_NAME_PATTERN = Pattern.compile(
"^[^/\\\\:*?\"<>|\\x00]+$"
);
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // 空值由 @NotBlank 等其他注解处理
}
boolean isValid = FILE_NAME_PATTERN.matcher(value).matches();
if (!isValid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
DataManagementErrorCode.FILE_NAME_INVALID.getMessage()
).addConstraintViolation();
}
return isValid;
}
}
@@ -0,0 +1,26 @@
package com.deepdata.datamanagement.interfaces.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 文件路径校验注解
* 验证文件路径不包含非法字符(允许 / 用于支持文件夹上传)
*
* @author DeepData
* @since 2026/03/12
*/
@Documented
@Constraint(validatedBy = ValidFilePathValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidFilePath {
String message() default "文件路径包含非法字符";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@@ -0,0 +1,54 @@
package com.deepdata.datamanagement.interfaces.validation;
import com.deepdata.datamanagement.infrastructure.exception.DataManagementErrorCode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
/**
* 文件路径校验器
* 允许路径分隔符 / 用于支持文件夹上传
*
* @author DeepData
* @since 2026/03/12
*/
public class ValidFilePathValidator implements ConstraintValidator<ValidFilePath, String> {
/**
* 文件路径正则表达式
* 不允许包含特殊字符: \ : * ? " < > | \0
* 允许字母、数字、中文、常见符号(- _ . space /)
* 注意:允许 / 是为了支持文件夹上传的相对路径
*/
private static final Pattern FILE_PATH_PATTERN = Pattern.compile(
"^[^\\\\:*?\"<>|\\x00]+$"
);
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // 空值由 @NotBlank 等其他注解处理
}
// 检查是否包含路径遍历序列 ..
if (value.contains("..")) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"文件路径不允许包含 '..'"
).addConstraintViolation();
return false;
}
boolean isValid = FILE_PATH_PATTERN.matcher(value).matches();
if (!isValid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
DataManagementErrorCode.FILE_NAME_INVALID.getMessage()
).addConstraintViolation();
}
return isValid;
}
}
@@ -0,0 +1,26 @@
package com.deepdata.datamanagement.interfaces.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 十六进制颜色格式校验注解
* 验证字符串是否符合十六进制颜色格式 (#RRGGBB 或 #RGB)
*
* @author DeepData
* @since 2026/02/11
*/
@Documented
@Constraint(validatedBy = ValidHexColorValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidHexColor {
String message() default "标签颜色格式不正确,应为十六进制颜色代码";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@@ -0,0 +1,43 @@
package com.deepdata.datamanagement.interfaces.validation;
import com.deepdata.datamanagement.infrastructure.exception.DataManagementErrorCode;
import com.deepdata.common.infrastructure.exception.ErrorCode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.hibernate.validator.internal.constraintvalidators.bv.size.SizeValidatorForCharSequence;
import java.util.regex.Pattern;
/**
* 十六进制颜色格式校验器
*
* @author DeepData
* @since 2026/02/11
*/
public class ValidHexColorValidator implements ConstraintValidator<ValidHexColor, String> {
/**
* 十六进制颜色正则表达式
* 支持 #RGB 和 #RRGGBB 格式
*/
private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // null 值由 @NotBlank 等其他注解处理
}
boolean isValid = HEX_COLOR_PATTERN.matcher(value).matches();
if (!isValid) {
// 自定义错误消息和错误码
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
DataManagementErrorCode.TAG_COLOR_INVALID.getMessage()
).addConstraintViolation();
}
return isValid;
}
}
@@ -0,0 +1,31 @@
package com.deepdata.datamanagement.interfaces.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 路径格式校验注解
* 验证路径格式和长度限制
*
* @author DeepData
* @since 2026/02/11
*/
@Documented
@Constraint(validatedBy = ValidPathValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidPath {
String message() default "路径格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/**
* 最大路径长度,默认500
*/
int maxLength() default 500;
}
@@ -0,0 +1,75 @@
package com.deepdata.datamanagement.interfaces.validation;
import com.deepdata.datamanagement.infrastructure.exception.DataManagementErrorCode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
/**
* 路径格式校验器
*
* @author DeepData
* @since 2026/02/11
*/
public class ValidPathValidator implements ConstraintValidator<ValidPath, String> {
/**
* 路径正则表达式
* 不允许以点开头(隐藏文件/目录)
* 不允许包含特殊字符如 \0, <, >, :, ", |, ?, *
*/
private static final Pattern PATH_PATTERN = Pattern.compile(
"^[^<>:\"|?*\\x00]+" // 不以点开头,不包含特殊字符
);
private int maxLength = 500;
@Override
public void initialize(ValidPath constraintAnnotation) {
this.maxLength = constraintAnnotation.maxLength();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // 空值由其他注解处理
}
// 检查长度
if (value.length() > maxLength) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
DataManagementErrorCode.PATH_TOO_LONG.getMessage()
).addConstraintViolation();
return false;
}
// 检查是否以点开头
if (value.startsWith(".")) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
DataManagementErrorCode.PREFIX_INVALID.getMessage()
).addConstraintViolation();
return false;
}
// 检查是否包含路径遍历序列 ..
if (value.contains("..")) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"路径不允许包含 '..'"
).addConstraintViolation();
return false;
}
// 检查是否包含非法字符
if (!PATH_PATTERN.matcher(value).matches()) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("路径包含非法字符").addConstraintViolation();
return false;
}
return true;
}
}
@@ -0,0 +1,14 @@
deepdata:
datamanagement:
file-storage:
upload-dir: ${FILE_UPLOAD_DIR:./uploads}
max-file-size: 10485760 # 10MB
max-request-size: 52428800 # 50MB
allowed-source-dirs:
- ${DATASET_BASE_PATH:/dataset}
- ${FILE_UPLOAD_DIR:./uploads}
cache:
ttl: 3600
max-size: 1000
# MyBatis is configured centrally in main-application (mapper-locations & aliases)
# to avoid list overriding issues when importing multiple module configs.
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.deepdata.datamanagement.infrastructure.persistence.mapper.DatasetFileMapper">
<sql id="Base_Column_List">
id, dataset_id, file_name, file_path, file_type, file_size, check_sum, tags, tags_updated_at, metadata, status,
upload_time, last_access_time, created_at, updated_at
</sql>
<select id="findById" parameterType="string"
resultType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_dataset_files
WHERE id = #{id}
</select>
<select id="findByDatasetId" parameterType="string"
resultType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_dataset_files
WHERE dataset_id = #{datasetId}
ORDER BY upload_time DESC
</select>
<select id="findByDatasetIdAndStatus" resultType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_dataset_files
WHERE dataset_id = #{datasetId}
AND status = #{status}
ORDER BY upload_time DESC
</select>
<select id="findByDatasetIdAndFileType" resultType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_dataset_files
WHERE dataset_id = #{datasetId}
AND file_type = #{fileType}
ORDER BY upload_time DESC
</select>
<select id="countByDatasetId" parameterType="string" resultType="long">
SELECT COUNT(*) FROM t_dm_dataset_files WHERE dataset_id = #{datasetId}
</select>
<select id="countCompletedByDatasetId" parameterType="string" resultType="long">
SELECT COUNT(*) FROM t_dm_dataset_files WHERE dataset_id = #{datasetId} AND status = 'COMPLETED'
</select>
<select id="sumSizeByDatasetId" parameterType="string" resultType="long">
SELECT COALESCE(SUM(file_size), 0) FROM t_dm_dataset_files WHERE dataset_id = #{datasetId}
</select>
<select id="findByDatasetIdAndFileName" resultType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_dataset_files
WHERE dataset_id = #{datasetId} AND file_name = #{fileName}
LIMIT 1
</select>
<select id="findAllByDatasetId" parameterType="string"
resultType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_dataset_files
WHERE dataset_id = #{datasetId}
ORDER BY upload_time DESC
</select>
<select id="findByCriteria" resultType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_dataset_files
WHERE dataset_id = #{datasetId}
<!-- Replace invalid XML '&&' with 'and' for MyBatis OGNL -->
<if test="fileType != null and fileType != ''">
AND file_type = #{fileType}
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
ORDER BY upload_time DESC
</select>
<update id="update" parameterType="com.deepdata.datamanagement.domain.model.dataset.DatasetFile">
UPDATE t_dm_dataset_files
SET file_name = #{fileName},
file_path = #{filePath},
file_type = #{fileType},
file_size = #{fileSize},
upload_time = #{uploadTime},
last_access_time = #{lastAccessTime},
status = #{status}
WHERE id = #{id}
</update>
<delete id="deleteById" parameterType="string">
DELETE FROM t_dm_dataset_files WHERE id = #{id}
</delete>
</mapper>
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.deepdata.datamanagement.infrastructure.persistence.mapper.DatasetMapper">
<sql id="Base_Column_List">
id, name, description, dataset_type, category, path, format, schema_info, size_bytes, file_count, record_count,
retention_days, tags, metadata, status, is_public, is_featured, version, created_at, updated_at, created_by, updated_by
</sql>
<sql id="Alias_D_Column_List">
d.id AS id,
d.name AS name,
d.description AS description,
d.dataset_type AS dataset_type,
d.category AS category,
d.path AS path,
d.format AS format,
d.schema_info AS schema_info,
d.size_bytes AS size_bytes,
d.file_count AS file_count,
d.record_count AS record_count,
d.retention_days AS retention_days,
d.tags AS tags,
d.metadata AS metadata,
d.status AS status,
d.is_public AS is_public,
d.is_featured AS is_featured,
d.version AS version,
d.created_at AS created_at,
d.updated_at AS updated_at,
d.created_by AS created_by,
d.updated_by AS updated_by
</sql>
<select id="findById" parameterType="string" resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_datasets
WHERE id = #{id}
</select>
<select id="findByName" parameterType="string"
resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_datasets
WHERE name = #{name}
LIMIT 1
</select>
<select id="findByStatus" parameterType="string"
resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_datasets
WHERE status = #{status}
ORDER BY updated_at DESC
</select>
<select id="findByCreatedBy" resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_datasets
WHERE created_by = #{createdBy}
ORDER BY created_at DESC
</select>
<select id="findByTypeCode" resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_datasets
WHERE dataset_type = #{typeCode}
ORDER BY created_at DESC
</select>
<select id="findByTagNames" resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT DISTINCT <include refid="Alias_D_Column_List"/>
FROM t_dm_datasets d
JOIN t_dm_dataset_tags dt ON d.id = dt.dataset_id
JOIN t_dm_tags t ON t.id = dt.tag_id
WHERE t.name IN
<foreach collection="tagNames" item="name" open="(" separator="," close=")">
#{name}
</foreach>
ORDER BY d.created_at DESC
</select>
<select id="findByKeyword" resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_datasets
WHERE name LIKE CONCAT('%', #{keyword}, '%')
OR description LIKE CONCAT('%', #{keyword}, '%')
ORDER BY created_at DESC
</select>
<select id="findByCriteria" resultType="com.deepdata.datamanagement.domain.model.dataset.Dataset">
SELECT DISTINCT <include refid="Alias_D_Column_List"/>
FROM t_dm_datasets d
LEFT JOIN t_dm_dataset_tags dt ON d.id = dt.dataset_id
LEFT JOIN t_dm_tags t ON t.id = dt.tag_id
<where>
<if test="typeCode != null and typeCode != ''">
AND d.dataset_type = #{typeCode}
</if>
<if test="status != null and status != ''">
AND d.status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND (d.name LIKE CONCAT('%', #{keyword}, '%') OR d.description LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="tagNames != null and tagNames.size > 0">
AND t.name IN
<foreach collection="tagNames" item="n" open="(" separator="," close=")">
#{n}
</foreach>
</if>
</where>
ORDER BY d.created_at DESC
</select>
<select id="countByCriteria" resultType="long">
SELECT COUNT(DISTINCT d.id)
FROM t_dm_datasets d
LEFT JOIN t_dm_dataset_tags dt ON d.id = dt.dataset_id
LEFT JOIN t_dm_tags t ON t.id = dt.tag_id
<where>
<if test="typeCode != null and typeCode != ''">
AND d.dataset_type = #{typeCode}
</if>
<if test="status != null and status != ''">
AND d.status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND (d.name LIKE CONCAT('%', #{keyword}, '%') OR d.description LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="tagNames != null and tagNames.size > 0">
AND t.name IN
<foreach collection="tagNames" item="n" open="(" separator="," close=")">
#{n}
</foreach>
</if>
</where>
</select>
<delete id="deleteById" parameterType="string">
DELETE FROM t_dm_datasets WHERE id = #{id}
</delete>
<select id="getAllDatasetStatistics" resultType="com.deepdata.datamanagement.interfaces.dto.AllDatasetStatisticsResponse">
SELECT
COUNT(*) AS total_datasets,
SUM(size_bytes) AS total_size,
SUM(file_count) AS total_files
FROM t_dm_datasets;
</select>
</mapper>
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.deepdata.datamanagement.infrastructure.persistence.mapper.TagMapper">
<resultMap id="TagResultMap" type="com.deepdata.datamanagement.domain.model.dataset.Tag">
<id column="id" property="id"/>
<result column="name" property="name"/>
<result column="description" property="description"/>
<result column="category" property="category"/>
<result column="color" property="color"/>
<result column="usage_count" property="usageCount"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<sql id="Base_Column_List">
id, name, description, category, color, usage_count, created_at, updated_at
</sql>
<select id="findById" parameterType="string" resultMap="TagResultMap">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_tags
WHERE id = #{id}
</select>
<select id="findByName" parameterType="string" resultMap="TagResultMap">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_tags
WHERE name = #{name}
LIMIT 1
</select>
<select id="findByNameIn" parameterType="list" resultMap="TagResultMap">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_tags
WHERE name IN
<foreach collection="list" item="n" open="(" separator="," close=")">
#{n}
</foreach>
</select>
<select id="findByKeyword" parameterType="string" resultMap="TagResultMap">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_tags
WHERE name LIKE CONCAT('%', #{keyword}, '%')
ORDER BY usage_count DESC, name ASC
</select>
<select id="findAllByOrderByUsageCountDesc" resultMap="TagResultMap">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_tags
ORDER BY usage_count DESC, name ASC
</select>
<insert id="insert" parameterType="com.deepdata.datamanagement.domain.model.dataset.Tag">
INSERT INTO t_dm_tags (id, name, description, category, color, usage_count)
VALUES (#{id}, #{name}, #{description}, #{category}, #{color}, #{usageCount})
</insert>
<update id="update" parameterType="com.deepdata.datamanagement.domain.model.dataset.Tag">
UPDATE t_dm_tags
SET name = #{name},
description = #{description},
category = #{category},
color = #{color},
usage_count = #{usageCount}
WHERE id = #{id}
</update>
<update id="updateUsageCount">
UPDATE t_dm_tags
SET usage_count = #{usageCount}
WHERE id = #{id}
</update>
<!-- Dataset & Tag relations -->
<insert id="insertDatasetTag">
INSERT INTO t_dm_dataset_tags (dataset_id, tag_id)
VALUES (#{datasetId}, #{tagId})
</insert>
<delete id="deleteDatasetTagsByDatasetId">
DELETE FROM t_dm_dataset_tags WHERE dataset_id = #{datasetId}
</delete>
<select id="findByDatasetId" parameterType="string" resultMap="TagResultMap">
SELECT t.id, t.name, t.description, t.category, t.color, t.usage_count, t.created_at, t.updated_at
FROM t_dm_tags t
JOIN t_dm_dataset_tags dt ON dt.tag_id = t.id
WHERE dt.dataset_id = #{datasetId}
ORDER BY t.usage_count DESC, t.name ASC
</select>
<delete id="deleteTagsById">
DELETE FROM t_dm_tags WHERE
id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<select id="findByIdIn" resultMap="TagResultMap">
SELECT <include refid="Base_Column_List"/>
FROM t_dm_tags
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
</mapper>