chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package com.xspaceagi.file.infra.config;
|
||||
|
||||
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 文件存储配置
|
||||
*/
|
||||
@Configuration
|
||||
public class FileStorageConfig {
|
||||
|
||||
@Bean
|
||||
public Map<String, FileStorageStrategy> storageStrategyMap(List<FileStorageStrategy> strategies) {
|
||||
return strategies.stream()
|
||||
.collect(Collectors.toMap(
|
||||
FileStorageStrategy::getStorageType,
|
||||
strategy -> strategy
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.xspaceagi.file.infra.config;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* MyBatis 配置
|
||||
*/
|
||||
@Configuration
|
||||
@MapperScan("com.xspaceagi.file.infra.dao.mapper")
|
||||
public class MyBatisConfig {
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.xspaceagi.file.infra.dao.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 文件记录实体
|
||||
* @TableName file_record
|
||||
*/
|
||||
@TableName(value = "file_record")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class FileRecord {
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
@TableField(value = "_tenant_id")
|
||||
private Long tenantId;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 来源对象类型(如:agent、knowledge、message等)
|
||||
*/
|
||||
private String targetType;
|
||||
|
||||
/**
|
||||
* 来源对象ID
|
||||
*/
|
||||
private Long targetId;
|
||||
|
||||
/**
|
||||
* 文件名称
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 文件大小(字节)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 文件类型(MIME类型)
|
||||
*/
|
||||
private String fileType;
|
||||
|
||||
/**
|
||||
* 文件扩展名
|
||||
*/
|
||||
private String fileExtension;
|
||||
|
||||
/**
|
||||
* 文件元数据(JSON格式,存储图片宽高、视频时长等)
|
||||
*/
|
||||
private String metadata;
|
||||
|
||||
/**
|
||||
* 文件存储标识key
|
||||
*/
|
||||
private String fileKey;
|
||||
|
||||
/**
|
||||
* 存储方式(cos:腾讯云COS, oss:阿里云OSS, s3:S3协议, file:本地存储)
|
||||
*/
|
||||
private String storageType;
|
||||
|
||||
/**
|
||||
* 文件访问URL
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 是否需要认证访问(0:公开, 1:需要认证)
|
||||
*/
|
||||
private Boolean authRequired;
|
||||
|
||||
/**
|
||||
* 文件状态(active:正常, deleted:已删除)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date created;
|
||||
|
||||
/**
|
||||
* 修改时间
|
||||
*/
|
||||
private Date modified;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.xspaceagi.file.infra.dao.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.xspaceagi.file.infra.dao.entity.FileRecord;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件记录Mapper
|
||||
*
|
||||
* @description 针对表【file_record(文件记录表)】的数据库操作Mapper
|
||||
* @Entity com.xspaceagi.file.infra.dao.entity.FileRecord
|
||||
*/
|
||||
public interface FileRecordMapper extends BaseMapper<FileRecord> {
|
||||
|
||||
/**
|
||||
* 根据租户ID和用户ID查询文件列表
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param userId 用户ID
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<FileRecord> findByTenantIdAndUserId(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 根据来源对象查询文件列表
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param targetType 来源对象类型
|
||||
* @param targetId 来源对象ID
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<FileRecord> findByTarget(@Param("tenantId") Long tenantId,
|
||||
@Param("targetType") String targetType,
|
||||
@Param("targetId") Long targetId);
|
||||
|
||||
/**
|
||||
* 根据文件key查询文件
|
||||
*
|
||||
* @param fileKey 文件key
|
||||
* @return 文件记录
|
||||
*/
|
||||
FileRecord findByFileKey(@Param("fileKey") String fileKey);
|
||||
|
||||
/**
|
||||
* 根据存储类型查询文件列表
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param storageType 存储类型
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<FileRecord> findByStorageType(@Param("tenantId") Long tenantId, @Param("storageType") String storageType);
|
||||
|
||||
/**
|
||||
* 批量更新文件状态
|
||||
*
|
||||
* @param ids 文件ID列表
|
||||
* @param status 状态
|
||||
* @return 更新数量
|
||||
*/
|
||||
int updateStatusByIds(@Param("ids") List<Long> ids, @Param("status") String status);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.xspaceagi.file.infra.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
import com.xspaceagi.file.domain.repository.FileRecordRepository;
|
||||
import com.xspaceagi.file.infra.dao.entity.FileRecord;
|
||||
import com.xspaceagi.file.infra.dao.mapper.FileRecordMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 文件记录仓储实现
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class FileRecordRepositoryImpl implements FileRecordRepository {
|
||||
|
||||
private final FileRecordMapper fileRecordMapper;
|
||||
|
||||
@Override
|
||||
public FileRecordDomain save(FileRecordDomain fileRecord) {
|
||||
FileRecord entity = toEntity(fileRecord);
|
||||
fileRecordMapper.insert(entity);
|
||||
return toDomain(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecordDomain findById(Long id) {
|
||||
FileRecord entity = fileRecordMapper.selectById(id);
|
||||
return entity != null ? toDomain(entity) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecordDomain findByFileKey(String fileKey) {
|
||||
FileRecord entity = fileRecordMapper.findByFileKey(fileKey);
|
||||
return entity != null ? toDomain(entity) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRecordDomain> findByTenantIdAndUserId(Long tenantId, Long userId) {
|
||||
List<FileRecord> entities = fileRecordMapper.findByTenantIdAndUserId(tenantId, userId);
|
||||
return entities.stream().map(this::toDomain).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRecordDomain> findByTarget(Long tenantId, String targetType, Long targetId) {
|
||||
List<FileRecord> entities = fileRecordMapper.findByTarget(tenantId, targetType, targetId);
|
||||
return entities.stream().map(this::toDomain).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRecordDomain> findByStorageType(Long tenantId, String storageType) {
|
||||
List<FileRecord> entities = fileRecordMapper.findByStorageType(tenantId, storageType);
|
||||
return entities.stream().map(this::toDomain).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean update(FileRecordDomain fileRecord) {
|
||||
FileRecord entity = toEntity(fileRecord);
|
||||
return fileRecordMapper.updateById(entity) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateStatusByIds(List<Long> ids, String status) {
|
||||
return fileRecordMapper.updateStatusByIds(ids, status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteById(Long id) {
|
||||
return fileRecordMapper.deleteById(id) > 0;
|
||||
}
|
||||
|
||||
private FileRecordDomain toDomain(FileRecord entity) {
|
||||
FileRecordDomain domain = new FileRecordDomain();
|
||||
BeanUtils.copyProperties(entity, domain);
|
||||
return domain;
|
||||
}
|
||||
|
||||
private FileRecord toEntity(FileRecordDomain domain) {
|
||||
FileRecord entity = new FileRecord();
|
||||
BeanUtils.copyProperties(domain, entity);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.xspaceagi.file.infra.storage;
|
||||
|
||||
import com.qcloud.cos.COSClient;
|
||||
import com.qcloud.cos.ClientConfig;
|
||||
import com.qcloud.cos.auth.BasicCOSCredentials;
|
||||
import com.qcloud.cos.auth.COSCredentials;
|
||||
import com.qcloud.cos.http.HttpMethodName;
|
||||
import com.qcloud.cos.model.ObjectMetadata;
|
||||
import com.qcloud.cos.model.PutObjectRequest;
|
||||
import com.qcloud.cos.region.Region;
|
||||
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
|
||||
/**
|
||||
* Tencent Cloud COS Storage Implementation
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
//@ConditionalOnProperty(name = "storage.type", havingValue = "cos")
|
||||
public class CosFileStorageStrategy implements FileStorageStrategy {
|
||||
|
||||
@Value("${cos.secret-id:}")
|
||||
private String secretId;
|
||||
|
||||
@Value("${cos.secret-key:}")
|
||||
private String secretKey;
|
||||
|
||||
@Value("${cos.region:ap-guangzhou}")
|
||||
private String region;
|
||||
|
||||
@Value("${cos.bucket-name:}")
|
||||
private String bucketName;
|
||||
|
||||
@Value("${cos.base-url:}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${cos.endpoint:}")
|
||||
private String endpoint;
|
||||
|
||||
private volatile COSClient cosClient;
|
||||
|
||||
private COSClient getCOSClient() {
|
||||
if (cosClient == null) {
|
||||
synchronized (this) {
|
||||
if (cosClient == null) {
|
||||
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
|
||||
|
||||
// If custom endpoint is configured, use UserSpecifiedEndpointBuilder
|
||||
if (endpoint != null && !endpoint.isEmpty()) {
|
||||
com.qcloud.cos.endpoint.UserSpecifiedEndpointBuilder endpointBuilder =
|
||||
new com.qcloud.cos.endpoint.UserSpecifiedEndpointBuilder(endpoint, endpoint);
|
||||
ClientConfig clientConfig = new ClientConfig(new Region(region));
|
||||
clientConfig.setEndpointBuilder(endpointBuilder);
|
||||
cosClient = new COSClient(cred, clientConfig);
|
||||
} else {
|
||||
ClientConfig clientConfig = new ClientConfig(new Region(region));
|
||||
cosClient = new COSClient(cred, clientConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cosClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
|
||||
try {
|
||||
COSClient client = getCOSClient();
|
||||
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
|
||||
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentType(contentType);
|
||||
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileKey, inputStream, metadata);
|
||||
client.putObject(putObjectRequest);
|
||||
|
||||
log.info("File uploaded to COS successfully: {}", fileKey);
|
||||
return fileKey;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to upload file to COS", e);
|
||||
throw new RuntimeException("File upload failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream download(String fileKey) {
|
||||
try {
|
||||
COSClient client = getCOSClient();
|
||||
return client.getObject(bucketName, fileKey).getObjectContent();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download file from COS: {}", fileKey, e);
|
||||
throw new RuntimeException("File download failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String fileKey) {
|
||||
try {
|
||||
COSClient client = getCOSClient();
|
||||
client.deleteObject(bucketName, fileKey);
|
||||
log.info("File deleted from COS successfully: {}", fileKey);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to delete file from COS: {}", fileKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUrl(String fileKey) {
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String fileKey, int expireSeconds) {
|
||||
return generatePresignedUrl(fileKey, expireSeconds, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String fileKey, int expireSeconds, String fileName) {
|
||||
try {
|
||||
COSClient client = getCOSClient();
|
||||
|
||||
// Remove leading slash from fileKey if present
|
||||
String objectKey = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
|
||||
|
||||
// Generate presigned URL
|
||||
java.util.Date expirationDate = new java.util.Date(System.currentTimeMillis() + expireSeconds * 1000L);
|
||||
|
||||
// Set response headers if fileName is provided
|
||||
java.util.Map<String, String> responseHeaders = new java.util.HashMap<>();
|
||||
if (fileName != null && !fileName.isEmpty()) {
|
||||
responseHeaders.put("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + "\"");
|
||||
}
|
||||
|
||||
java.net.URL url = client.generatePresignedUrl(bucketName, objectKey, expirationDate, HttpMethodName.GET, new HashMap<>(), responseHeaders, false, false);
|
||||
|
||||
String signedUrl = url.toString();
|
||||
|
||||
// If custom baseUrl is configured, replace default COS domain
|
||||
if (baseUrl != null && !baseUrl.isEmpty()) {
|
||||
try {
|
||||
java.net.URL originalUrl = new java.net.URL(signedUrl);
|
||||
String path = originalUrl.getPath();
|
||||
String query = originalUrl.getQuery();
|
||||
|
||||
// Remove trailing slash from baseUrl if present
|
||||
String normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||
|
||||
// Build new URL: baseUrl + path + query
|
||||
signedUrl = normalizedBaseUrl + path + (query != null ? "?" + query : "");
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to replace baseUrl, using original URL: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("COS presigned URL generated successfully: {}", fileKey);
|
||||
return signedUrl;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate COS presigned URL: {}", fileKey, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStorageType() {
|
||||
return "cos";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.xspaceagi.file.infra.storage;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* File Key Generator Utility
|
||||
*/
|
||||
public class FileKeyGenerator {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
/**
|
||||
* Generate file key
|
||||
* Format: /storageType/businessType/date/uuid.ext
|
||||
* Example: /s3/agent/20260417/a1b2c3d4.jpg
|
||||
*
|
||||
* @param fileName Original file name
|
||||
* @param targetType Business type
|
||||
* @param storageType Storage type
|
||||
* @return File key
|
||||
*/
|
||||
public static String generate(String fileName, String targetType, String storageType) {
|
||||
String extension = extractExtension(fileName);
|
||||
String date = LocalDate.now().format(DATE_FORMATTER);
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
String businessType = targetType != null ? targetType.toLowerCase() : "default";
|
||||
|
||||
return String.format("%s/%s/%s/%s%s", storageType, businessType, date, uuid, extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension
|
||||
*
|
||||
* @param fileName File name
|
||||
* @return Extension (including dot), or empty string if no extension
|
||||
*/
|
||||
private static String extractExtension(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
|
||||
return fileName.substring(dotIndex);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.xspaceagi.file.infra.storage;
|
||||
|
||||
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
/**
|
||||
* Local File Storage Implementation
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class LocalFileStorageStrategy implements FileStorageStrategy {
|
||||
|
||||
@Value("${local.upload.folder:}")
|
||||
private String uploadFolder;
|
||||
|
||||
@Value("${file.uploadFolder:/tmp/uploads}")
|
||||
private String oldUploadFolder;
|
||||
|
||||
@Override
|
||||
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
|
||||
try {
|
||||
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
|
||||
Path targetPath = Paths.get(StringUtils.isBlank(uploadFolder) ? oldUploadFolder : uploadFolder, fileKey);
|
||||
|
||||
Files.createDirectories(targetPath.getParent());
|
||||
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
log.info("File uploaded successfully: {}", fileKey);
|
||||
return fileKey;
|
||||
} catch (IOException e) {
|
||||
log.error("File upload failed", e);
|
||||
throw new RuntimeException("File upload failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream download(String fileKey) {
|
||||
try {
|
||||
Path filePath = Paths.get(StringUtils.isBlank(uploadFolder) ? oldUploadFolder : uploadFolder, fileKey);
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new FileNotFoundException("File not found: " + fileKey);
|
||||
}
|
||||
return Files.newInputStream(filePath);
|
||||
} catch (IOException e) {
|
||||
log.error("File download failed: {}", fileKey, e);
|
||||
throw new RuntimeException("File download failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String fileKey) {
|
||||
try {
|
||||
Path filePath = Paths.get(StringUtils.isBlank(uploadFolder) ? oldUploadFolder : uploadFolder, fileKey);
|
||||
return Files.deleteIfExists(filePath);
|
||||
} catch (IOException e) {
|
||||
log.error("File deletion failed: {}", fileKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUrl(String fileKey) {
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStorageType() {
|
||||
return "local";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.xspaceagi.file.infra.storage;
|
||||
|
||||
import com.aliyun.oss.ClientBuilderConfiguration;
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.aliyun.oss.common.auth.CredentialsProvider;
|
||||
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
|
||||
import com.aliyun.oss.common.comm.SignVersion;
|
||||
import com.aliyun.oss.model.ObjectMetadata;
|
||||
import com.aliyun.oss.model.PutObjectRequest;
|
||||
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Alibaba Cloud OSS Storage Implementation
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
//@ConditionalOnProperty(name = "storage.type", havingValue = "oss")
|
||||
public class OssFileStorageStrategy implements FileStorageStrategy {
|
||||
|
||||
@Value("${oss.endpoint:}")
|
||||
private String endpoint;
|
||||
|
||||
@Value("${oss.access-key-id:}")
|
||||
private String accessKeyId;
|
||||
|
||||
@Value("${oss.access-key-secret:}")
|
||||
private String accessKeySecret;
|
||||
|
||||
@Value("${oss.region-name:cn-hangzhou}")
|
||||
private String regionName;
|
||||
|
||||
@Value("${oss.bucket-name:}")
|
||||
private String bucketName;
|
||||
|
||||
private volatile OSS ossClient;
|
||||
|
||||
private OSS getOSSClient() {
|
||||
if (ossClient == null) {
|
||||
synchronized (this) {
|
||||
if (ossClient == null) {
|
||||
CredentialsProvider credentialsProvider = CredentialsProviderFactory.newDefaultCredentialProvider(accessKeyId, accessKeySecret);
|
||||
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
|
||||
clientBuilderConfiguration.setSupportCname(true);
|
||||
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
|
||||
ossClient = OSSClientBuilder.create()
|
||||
.endpoint(endpoint)
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.clientConfiguration(clientBuilderConfiguration)
|
||||
.region(regionName)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return ossClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
|
||||
try {
|
||||
OSS client = getOSSClient();
|
||||
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
|
||||
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentType(contentType);
|
||||
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileKey, inputStream, metadata);
|
||||
client.putObject(putObjectRequest);
|
||||
|
||||
log.info("File uploaded to OSS successfully: {}", fileKey);
|
||||
return fileKey;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to upload file to OSS", e);
|
||||
throw new RuntimeException("File upload failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream download(String fileKey) {
|
||||
try {
|
||||
OSS client = getOSSClient();
|
||||
return client.getObject(bucketName, fileKey).getObjectContent();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download file from OSS: {}", fileKey, e);
|
||||
throw new RuntimeException("File download failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String fileKey) {
|
||||
try {
|
||||
OSS client = getOSSClient();
|
||||
client.deleteObject(bucketName, fileKey);
|
||||
log.info("File deleted from OSS successfully: {}", fileKey);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to delete file from OSS: {}", fileKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUrl(String fileKey) {
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String fileKey, int expireSeconds) {
|
||||
return generatePresignedUrl(fileKey, expireSeconds, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String fileKey, int expireSeconds, String fileName) {
|
||||
try {
|
||||
OSS client = getOSSClient();
|
||||
|
||||
// Remove leading slash from fileKey if present
|
||||
String objectKey = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
|
||||
|
||||
// Generate presigned URL
|
||||
java.util.Date expirationDate = new java.util.Date(System.currentTimeMillis() + expireSeconds * 1000L);
|
||||
|
||||
// Create GeneratePresignedUrlRequest
|
||||
com.aliyun.oss.model.GeneratePresignedUrlRequest request =
|
||||
new com.aliyun.oss.model.GeneratePresignedUrlRequest(bucketName, objectKey);
|
||||
request.setExpiration(expirationDate);
|
||||
|
||||
// Add Content-Disposition header if fileName is provided
|
||||
if (fileName != null && !fileName.isEmpty()) {
|
||||
com.aliyun.oss.model.ResponseHeaderOverrides responseHeaders =
|
||||
new com.aliyun.oss.model.ResponseHeaderOverrides();
|
||||
responseHeaders.setContentDisposition("attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + "\"");
|
||||
request.setResponseHeaders(responseHeaders);
|
||||
}
|
||||
|
||||
java.net.URL url = client.generatePresignedUrl(request);
|
||||
|
||||
log.info("OSS presigned URL generated successfully: {}", fileKey);
|
||||
return url.toString();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate OSS presigned URL: {}", fileKey, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStorageType() {
|
||||
return "oss";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.xspaceagi.file.infra.storage;
|
||||
|
||||
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* S3 Protocol Storage Implementation
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
//@ConditionalOnProperty(name = "storage.type", havingValue = "s3")
|
||||
public class S3FileStorageStrategy implements FileStorageStrategy {
|
||||
|
||||
@Value("${s3.endpoint:}")
|
||||
private String endpoint;
|
||||
|
||||
@Value("${s3.access-key:}")
|
||||
private String accessKey;
|
||||
|
||||
@Value("${s3.secret-key:}")
|
||||
private String secretKey;
|
||||
|
||||
@Value("${s3.bucket-name:}")
|
||||
private String bucketName;
|
||||
|
||||
@Value("${s3.region:us-east-1}")
|
||||
private String region;
|
||||
|
||||
private volatile S3Client s3Client;
|
||||
private volatile S3Presigner s3Presigner;
|
||||
|
||||
private S3Client getS3Client() {
|
||||
if (s3Client == null) {
|
||||
synchronized (this) {
|
||||
if (s3Client == null) {
|
||||
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
|
||||
s3Client = S3Client.builder()
|
||||
.endpointOverride(URI.create(endpoint))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(credentials))
|
||||
.region(Region.of(region))
|
||||
.forcePathStyle(true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return s3Client;
|
||||
}
|
||||
|
||||
private S3Presigner getS3Presigner() {
|
||||
if (s3Presigner == null) {
|
||||
synchronized (this) {
|
||||
if (s3Presigner == null) {
|
||||
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
|
||||
|
||||
software.amazon.awssdk.services.s3.presigner.S3Presigner.Builder builder = S3Presigner.builder()
|
||||
.endpointOverride(URI.create(endpoint))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(credentials))
|
||||
.region(Region.of(region));
|
||||
|
||||
// Configure path-style access for presigner
|
||||
builder.serviceConfiguration(
|
||||
software.amazon.awssdk.services.s3.S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(true)
|
||||
.build()
|
||||
);
|
||||
|
||||
s3Presigner = builder.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return s3Presigner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
|
||||
try {
|
||||
S3Client client = getS3Client();
|
||||
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
|
||||
|
||||
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(fileKey)
|
||||
.contentType(contentType)
|
||||
.build();
|
||||
|
||||
client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, inputStream.available()));
|
||||
|
||||
log.info("File uploaded to S3 successfully: {}", fileKey);
|
||||
return fileKey;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to upload file to S3", e);
|
||||
throw new RuntimeException("File upload failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream download(String fileKey) {
|
||||
try {
|
||||
S3Client client = getS3Client();
|
||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(fileKey)
|
||||
.build();
|
||||
return client.getObject(getObjectRequest);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to download file from S3: {}", fileKey, e);
|
||||
throw new RuntimeException("File download failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String fileKey) {
|
||||
try {
|
||||
S3Client client = getS3Client();
|
||||
DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(fileKey)
|
||||
.build();
|
||||
client.deleteObject(deleteObjectRequest);
|
||||
log.info("File deleted from S3 successfully: {}", fileKey);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to delete file from S3: {}", fileKey, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUrl(String fileKey) {
|
||||
return fileKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String fileKey, int expireSeconds) {
|
||||
return generatePresignedUrl(fileKey, expireSeconds, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String fileKey, int expireSeconds, String fileName) {
|
||||
try {
|
||||
S3Presigner presigner = getS3Presigner();
|
||||
|
||||
// Remove leading slash from fileKey if present
|
||||
String objectKey = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
|
||||
|
||||
GetObjectRequest.Builder requestBuilder = GetObjectRequest.builder()
|
||||
.bucket(bucketName)
|
||||
.key(objectKey);
|
||||
|
||||
// Add Content-Disposition header if fileName is provided
|
||||
if (fileName != null && !fileName.isEmpty()) {
|
||||
requestBuilder.responseContentDisposition("attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + "\"");
|
||||
}
|
||||
|
||||
GetObjectRequest getObjectRequest = requestBuilder.build();
|
||||
|
||||
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
|
||||
.signatureDuration(Duration.ofSeconds(expireSeconds))
|
||||
.getObjectRequest(getObjectRequest)
|
||||
.build();
|
||||
|
||||
PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
|
||||
String url = presignedRequest.url().toString();
|
||||
|
||||
log.info("S3 presigned URL generated successfully: {}", fileKey);
|
||||
return url;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate S3 presigned URL: {}", fileKey, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStorageType() {
|
||||
return "s3";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?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.xspaceagi.file.infra.dao.mapper.FileRecordMapper">
|
||||
|
||||
<resultMap id="BaseResultMap" type="com.xspaceagi.file.infra.dao.entity.FileRecord">
|
||||
<id property="id" column="id" jdbcType="BIGINT"/>
|
||||
<result property="tenantId" column="_tenant_id" jdbcType="BIGINT"/>
|
||||
<result property="userId" column="user_id" jdbcType="BIGINT"/>
|
||||
<result property="targetType" column="target_type" jdbcType="VARCHAR"/>
|
||||
<result property="targetId" column="target_id" jdbcType="BIGINT"/>
|
||||
<result property="fileName" column="file_name" jdbcType="VARCHAR"/>
|
||||
<result property="fileSize" column="file_size" jdbcType="BIGINT"/>
|
||||
<result property="fileType" column="file_type" jdbcType="VARCHAR"/>
|
||||
<result property="fileExtension" column="file_extension" jdbcType="VARCHAR"/>
|
||||
<result property="metadata" column="metadata" jdbcType="VARCHAR"/>
|
||||
<result property="fileKey" column="file_key" jdbcType="VARCHAR"/>
|
||||
<result property="storageType" column="storage_type" jdbcType="VARCHAR"/>
|
||||
<result property="fileUrl" column="file_url" jdbcType="VARCHAR"/>
|
||||
<result property="authRequired" column="auth_required" jdbcType="BOOLEAN"/>
|
||||
<result property="status" column="status" jdbcType="VARCHAR"/>
|
||||
<result property="created" column="created" jdbcType="TIMESTAMP"/>
|
||||
<result property="modified" column="modified" jdbcType="TIMESTAMP"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
id, _tenant_id, user_id, target_type, target_id, file_name, file_size, file_type,
|
||||
file_extension, metadata, file_key, storage_type, file_url, auth_required, status, created, modified
|
||||
</sql>
|
||||
|
||||
<select id="findByTenantIdAndUserId" resultMap="BaseResultMap">
|
||||
SELECT
|
||||
<include refid="Base_Column_List"/>
|
||||
FROM file_record
|
||||
WHERE _tenant_id = #{tenantId}
|
||||
AND user_id = #{userId}
|
||||
AND status = 'active'
|
||||
ORDER BY created DESC
|
||||
</select>
|
||||
|
||||
<select id="findByTarget" resultMap="BaseResultMap">
|
||||
SELECT
|
||||
<include refid="Base_Column_List"/>
|
||||
FROM file_record
|
||||
WHERE _tenant_id = #{tenantId}
|
||||
AND target_type = #{targetType}
|
||||
AND target_id = #{targetId}
|
||||
AND status = 'active'
|
||||
ORDER BY created DESC
|
||||
</select>
|
||||
|
||||
<select id="findByFileKey" resultMap="BaseResultMap">
|
||||
SELECT
|
||||
<include refid="Base_Column_List"/>
|
||||
FROM file_record
|
||||
WHERE file_key = #{fileKey}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="findByStorageType" resultMap="BaseResultMap">
|
||||
SELECT
|
||||
<include refid="Base_Column_List"/>
|
||||
FROM file_record
|
||||
WHERE _tenant_id = #{tenantId}
|
||||
AND storage_type = #{storageType}
|
||||
AND status = 'active'
|
||||
ORDER BY created DESC
|
||||
</select>
|
||||
|
||||
<update id="updateStatusByIds">
|
||||
UPDATE file_record
|
||||
SET status = #{status},
|
||||
modified = NOW()
|
||||
WHERE id IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user