chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -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
));
}
}

View File

@@ -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 {
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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";
}
}

View File

@@ -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 "";
}
}

View File

@@ -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";
}
}

View File

@@ -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";
}
}

View File

@@ -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";
}
}

View File

@@ -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>