chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-api</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-spec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-application</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-infra</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-application</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.xspaceagi.file.application.service;
|
||||
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* File Management Service Interface
|
||||
*/
|
||||
public interface FileManagementService {
|
||||
|
||||
/**
|
||||
* Upload file
|
||||
*
|
||||
* @param file File to upload
|
||||
* @param tenantId Tenant ID
|
||||
* @param userId User ID
|
||||
* @param targetType Target object type
|
||||
* @param targetId Target object ID
|
||||
* @param metadata Metadata
|
||||
* @param isAuthRequired Whether authentication is required
|
||||
* @return File record
|
||||
*/
|
||||
FileRecordDomain uploadFile(MultipartFile file, Long tenantId, Long userId,
|
||||
String targetType, Long targetId, String metadata, boolean isAuthRequired);
|
||||
|
||||
/**
|
||||
* Download file
|
||||
*
|
||||
* @param fileKey File key
|
||||
* @return File input stream
|
||||
*/
|
||||
InputStream downloadFile(String fileKey);
|
||||
|
||||
/**
|
||||
* Delete file
|
||||
*
|
||||
* @param fileId File ID
|
||||
* @return Success status
|
||||
*/
|
||||
boolean deleteFile(Long fileId);
|
||||
|
||||
/**
|
||||
* Batch delete files
|
||||
*
|
||||
* @param fileIds List of file IDs
|
||||
* @return Success status
|
||||
*/
|
||||
boolean batchDeleteFiles(List<Long> fileIds);
|
||||
|
||||
/**
|
||||
* Get file record by ID
|
||||
*
|
||||
* @param fileId File ID
|
||||
* @return File record
|
||||
*/
|
||||
FileRecordDomain getFileById(Long fileId);
|
||||
|
||||
/**
|
||||
* Get file record by key
|
||||
*
|
||||
* @param fileKey File key
|
||||
* @return File record
|
||||
*/
|
||||
FileRecordDomain getFileByKey(String fileKey);
|
||||
|
||||
/**
|
||||
* List user files
|
||||
*
|
||||
* @param tenantId Tenant ID
|
||||
* @param userId User ID
|
||||
* @return List of file records
|
||||
*/
|
||||
List<FileRecordDomain> listUserFiles(Long tenantId, Long userId);
|
||||
|
||||
/**
|
||||
* List target files
|
||||
*
|
||||
* @param tenantId Tenant ID
|
||||
* @param targetType Target object type
|
||||
* @param targetId Target object ID
|
||||
* @return List of file records
|
||||
*/
|
||||
List<FileRecordDomain> listTargetFiles(Long tenantId, String targetType, Long targetId);
|
||||
|
||||
/**
|
||||
* Get file access URL
|
||||
*
|
||||
* @param fileKey File key
|
||||
* @return Access URL
|
||||
*/
|
||||
String getFileUrl(String fileKey);
|
||||
|
||||
/**
|
||||
* Generate presigned URL (for cloud storage)
|
||||
*
|
||||
* @param expireSeconds Expiration time in seconds
|
||||
* @return Presigned URL
|
||||
*/
|
||||
String generatePresignedUrl(String fileKey, int expireSeconds);
|
||||
|
||||
/**
|
||||
* Generate presigned URL by storage type (for cloud storage, no database query needed)
|
||||
*
|
||||
* @param fileKey File key
|
||||
* @param storageType Storage type
|
||||
* @param expireSeconds Expiration time in seconds
|
||||
* @return Presigned URL
|
||||
*/
|
||||
String generatePresignedUrlByType(String fileKey, String storageType, int expireSeconds, Integer download);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.xspaceagi.file.application.service.impl;
|
||||
|
||||
import com.xspaceagi.file.application.service.FileManagementService;
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
import com.xspaceagi.file.sdk.IFileAccessService;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.utils.RedisUtil;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FileAccessServiceImpl implements IFileAccessService {
|
||||
|
||||
@Resource
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Value("${storage.type}")
|
||||
private String storageType;
|
||||
|
||||
@Resource
|
||||
private FileManagementService fileManagementService;
|
||||
|
||||
public String getFileUrlWithAk(String fileUrl) {
|
||||
return getFileUrlWithAk(fileUrl, false);
|
||||
}
|
||||
|
||||
public String getFileUrlWithAk(String fileUrl, boolean returnOriginalUrl) {
|
||||
if (fileUrl == null) {
|
||||
return null;
|
||||
}
|
||||
String path;
|
||||
try {
|
||||
path = getUrlPath(fileUrl);
|
||||
} catch (Exception e) {
|
||||
return fileUrl;
|
||||
}
|
||||
if (!path.startsWith("/api/f/") && !path.startsWith("/api/file/")) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
// 从 fileUrl 中提取 storageType 和 fileKey
|
||||
// URL 格式:http://127.0.0.1:8081/api/f/{fileKey}
|
||||
// fileKey 格式:{storageType}/{businessType}/{date}/{uuid}.{ext}
|
||||
String extractedStorageType = null;
|
||||
String fileKey = null;
|
||||
try {
|
||||
int apiIndex = fileUrl.indexOf("/api/f/");
|
||||
if (apiIndex != -1) {
|
||||
fileKey = fileUrl.substring(apiIndex + "/api/f/".length());
|
||||
// 移除查询参数
|
||||
int queryIndex = fileKey.indexOf('?');
|
||||
if (queryIndex != -1) {
|
||||
fileKey = fileKey.substring(0, queryIndex);
|
||||
}
|
||||
// 提取存储类型(fileKey 的第一段)
|
||||
int slashIndex = fileKey.indexOf('/');
|
||||
if (slashIndex != -1) {
|
||||
extractedStorageType = fileKey.substring(0, slashIndex);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 提取失败,使用配置的 storageType
|
||||
}
|
||||
|
||||
boolean isCurrentSiteFile = true;
|
||||
TenantConfigDto tenantConfig = RequestContext.get() != null ? (TenantConfigDto) RequestContext.get().getTenantConfig() : null;
|
||||
if (tenantConfig != null && tenantConfig.getSiteUrl() != null) {
|
||||
try {
|
||||
URL siteUrl = new URL(tenantConfig.getSiteUrl());
|
||||
URL fileUrlObj = new URL(fileUrl);
|
||||
|
||||
// Compare host (domain) between siteUrl and fileUrl
|
||||
String siteHost = siteUrl.getHost();
|
||||
String fileHost = fileUrlObj.getHost();
|
||||
|
||||
isCurrentSiteFile = siteHost != null && siteHost.equalsIgnoreCase(fileHost);
|
||||
} catch (MalformedURLException e) {
|
||||
// If URL parsing fails, assume it's current site file
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("fileUrl {}, isCurrentSiteFile {}", fileUrl, isCurrentSiteFile);
|
||||
if (!isCurrentSiteFile && fileKey != null) {
|
||||
FileRecordDomain fileByKey = fileManagementService.getFileByKey(fileKey);
|
||||
if (fileByKey == null) {
|
||||
return fileUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 URL 中的 ak 参数
|
||||
if (fileUrl.contains("ak=")) {
|
||||
int akIndex = fileUrl.indexOf("ak=");
|
||||
int ampIndex = fileUrl.indexOf('&', akIndex);
|
||||
if (akIndex > 0 && fileUrl.charAt(akIndex - 1) == '?') {
|
||||
// ak 是第一个参数:?ak=xxx 或 ?ak=xxx&other=yyy
|
||||
if (ampIndex != -1) {
|
||||
fileUrl = fileUrl.substring(0, akIndex) + fileUrl.substring(ampIndex + 1);
|
||||
} else {
|
||||
fileUrl = fileUrl.substring(0, akIndex - 1);
|
||||
}
|
||||
} else if (akIndex > 0 && fileUrl.charAt(akIndex - 1) == '&') {
|
||||
// ak 是中间或最后的参数:&ak=xxx 或 &ak=xxx&other=yyy
|
||||
if (ampIndex != -1) {
|
||||
fileUrl = fileUrl.substring(0, akIndex - 1) + fileUrl.substring(ampIndex);
|
||||
} else {
|
||||
fileUrl = fileUrl.substring(0, akIndex - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean isLocalFile = "local".equals(extractedStorageType) || fileUrl.contains("/api/file/");
|
||||
if (!returnOriginalUrl || isLocalFile) {
|
||||
Object ak = redisUtil.get("file.ak:" + path);
|
||||
if (Objects.nonNull(ak)) {
|
||||
return fileUrl + "?ak=" + ak;
|
||||
}
|
||||
ak = UUID.randomUUID().toString().replace("-", "");
|
||||
redisUtil.set("file.ak:" + path, ak.toString(), 60 * 60 * 24);
|
||||
return fileUrl + "?ak=" + ak;
|
||||
}
|
||||
String url = fileManagementService.generatePresignedUrlByType(fileKey, extractStorageTypeFromFileKey(fileKey), 60 * 60 * 24, 0);
|
||||
return url;
|
||||
}
|
||||
|
||||
public void checkFileUrlAk(String uri, String ak) {
|
||||
if (!"file".equals(storageType) && uri.contains("/api/file")) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object ak0 = redisUtil.get("file.ak:" + uri);
|
||||
if (ak0 == null || !ak0.equals(ak)) {
|
||||
throw new IllegalArgumentException("Invalid file URL");
|
||||
}
|
||||
}
|
||||
|
||||
public void checkFileUrlAk0(String uri, String ak) {
|
||||
Object ak0 = redisUtil.get("file.ak:" + uri);
|
||||
if (ak0 == null || !ak0.equals(ak)) {
|
||||
throw new IllegalArgumentException("Invalid file URL");
|
||||
}
|
||||
}
|
||||
|
||||
private static String getUrlPath(String fileUrl) throws MalformedURLException {
|
||||
URL url;
|
||||
url = new URL(fileUrl);
|
||||
return url.getPath();
|
||||
}
|
||||
|
||||
private String extractStorageTypeFromFileKey(String fileKey) {
|
||||
if (StringUtils.isBlank(fileKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove leading slash
|
||||
String key = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
|
||||
|
||||
// Extract first segment as storage type
|
||||
int firstSlash = key.indexOf('/');
|
||||
if (firstSlash > 0) {
|
||||
return key.substring(0, firstSlash);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.xspaceagi.file.application.service.impl;
|
||||
|
||||
import com.xspaceagi.file.application.service.FileManagementService;
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
import com.xspaceagi.file.domain.repository.FileRecordRepository;
|
||||
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
|
||||
import com.xspaceagi.system.application.dto.TenantConfigDto;
|
||||
import com.xspaceagi.system.application.service.TenantConfigApplicationService;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.tenant.thread.TenantFunctions;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* File Management Service Implementation
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FileManagementServiceImpl implements FileManagementService {
|
||||
|
||||
private final FileRecordRepository fileRecordRepository;
|
||||
private final Map<String, FileStorageStrategy> storageStrategyMap;
|
||||
private final TenantConfigApplicationService tenantConfigApplicationService;
|
||||
|
||||
@Value("${storage.type:file}")
|
||||
private String defaultStorageType;
|
||||
|
||||
private FileStorageStrategy getStorageStrategy() {
|
||||
if ("file".equals(defaultStorageType)){
|
||||
defaultStorageType = "local";
|
||||
}
|
||||
return storageStrategyMap.values().stream()
|
||||
.filter(strategy -> strategy.getStorageType().equals(defaultStorageType))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("Storage strategy not found: " + defaultStorageType));
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecordDomain uploadFile(MultipartFile file, Long tenantId, Long userId,
|
||||
String targetType, Long targetId, String metadata, boolean isAuthRequired) {
|
||||
|
||||
FileStorageStrategy strategy = getStorageStrategy();
|
||||
|
||||
// Upload file, pass targetType for fileKey generation
|
||||
String fileKey;
|
||||
try {
|
||||
fileKey = strategy.upload(file.getInputStream(), file.getOriginalFilename(),
|
||||
file.getContentType(), targetType);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
// Get fileBaseUrl from tenant config (async IM callbacks may have no RequestContext)
|
||||
RequestContext<?> requestContext = RequestContext.get();
|
||||
TenantConfigDto tenantConfigDto = requestContext != null
|
||||
? (TenantConfigDto) requestContext.getTenantConfig()
|
||||
: null;
|
||||
if (tenantConfigDto == null && tenantId != null) {
|
||||
tenantConfigDto = tenantConfigApplicationService.getTenantConfig(tenantId);
|
||||
}
|
||||
String fileBaseUrl = tenantConfigDto != null ? tenantConfigDto.getSiteUrl() : "http://localhost:8081";
|
||||
|
||||
// Generate fileUrl: fileBaseUrl/api/f/fileKey
|
||||
String fileUrl = fileBaseUrl + "/api/f/" + fileKey;
|
||||
|
||||
// Get file extension
|
||||
String fileName = file.getOriginalFilename();
|
||||
String fileExtension = getFileExtension(fileName);
|
||||
|
||||
// Save file record
|
||||
FileRecordDomain fileRecord = FileRecordDomain.builder()
|
||||
.tenantId(tenantId)
|
||||
.userId(userId)
|
||||
.targetType(targetType)
|
||||
.targetId(targetId)
|
||||
.fileName(fileName)
|
||||
.fileSize(file.getSize())
|
||||
.fileType(file.getContentType())
|
||||
.fileExtension(fileExtension)
|
||||
.metadata(metadata)
|
||||
.fileKey(fileKey)
|
||||
.storageType(strategy.getStorageType())
|
||||
.fileUrl(fileUrl)
|
||||
.authRequired(isAuthRequired)
|
||||
.status("active")
|
||||
.created(new Date())
|
||||
.modified(new Date())
|
||||
.build();
|
||||
|
||||
return fileRecordRepository.save(fileRecord);
|
||||
|
||||
}
|
||||
|
||||
private static String getFileExtension(String fileName) {
|
||||
String fileExtension = "";
|
||||
if (fileName != null && fileName.contains(".")) {
|
||||
fileExtension = fileName.substring(fileName.lastIndexOf(".") + 1);
|
||||
// List<String> fileTypes = List.of("pdf", "txt", "doc", "docx", "md", "json", "xml", "xls", "xlsx", "ppt", "pptx", "mp4", "mov", "mp3", "wav", "aac", "flac", "ogg", "wma", "aiff", "m4a", "amr", "midi", "opus", "ra", "zip", "rar", "7z", "tar", "gz", "bz2", "tgz", "tar.gz", "tar.bz2", "tar.7z", "tar.gz", "jpg", "jpeg", "jpe", "png", "gif", "bmp", "ico", "icns", "svg", "webp", "heic", "mkv", "webm");
|
||||
// if (!fileTypes.contains(fileExtension.toLowerCase())) {
|
||||
// throw new BizException("Unsupported file type");
|
||||
// }
|
||||
}
|
||||
return fileExtension;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream downloadFile(String fileKey) {
|
||||
FileRecordDomain fileRecord = fileRecordRepository.findByFileKey(fileKey);
|
||||
if (fileRecord == null) {
|
||||
throw new RuntimeException("File not found");
|
||||
}
|
||||
|
||||
FileStorageStrategy strategy = storageStrategyMap.values().stream()
|
||||
.filter(s -> s.getStorageType().equals(fileRecord.getStorageType()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("Storage strategy not found"));
|
||||
|
||||
return strategy.download(fileKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteFile(Long fileId) {
|
||||
FileRecordDomain fileRecord = fileRecordRepository.findById(fileId);
|
||||
if (fileRecord == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete file from storage
|
||||
FileStorageStrategy strategy = storageStrategyMap.values().stream()
|
||||
.filter(s -> s.getStorageType().equals(fileRecord.getStorageType()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (strategy != null) {
|
||||
strategy.delete(fileRecord.getFileKey());
|
||||
}
|
||||
|
||||
// Physically delete database record
|
||||
return fileRecordRepository.deleteById(fileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean batchDeleteFiles(List<Long> fileIds) {
|
||||
if (fileIds == null || fileIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
boolean allSuccess = true;
|
||||
for (Long fileId : fileIds) {
|
||||
if (fileId == null) {
|
||||
allSuccess = false;
|
||||
continue;
|
||||
}
|
||||
boolean deleted = deleteFile(fileId);
|
||||
if (!deleted) {
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
return allSuccess;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecordDomain getFileById(Long fileId) {
|
||||
return fileRecordRepository.findById(fileId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileRecordDomain getFileByKey(String fileKey) {
|
||||
return TenantFunctions.callWithIgnoreCheck(() -> fileRecordRepository.findByFileKey(fileKey));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRecordDomain> listUserFiles(Long tenantId, Long userId) {
|
||||
return fileRecordRepository.findByTenantIdAndUserId(tenantId, userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileRecordDomain> listTargetFiles(Long tenantId, String targetType, Long targetId) {
|
||||
return fileRecordRepository.findByTarget(tenantId, targetType, targetId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUrl(String fileKey) {
|
||||
FileRecordDomain fileRecord = fileRecordRepository.findByFileKey(fileKey);
|
||||
if (fileRecord == null) {
|
||||
throw new RuntimeException("File not found");
|
||||
}
|
||||
return fileRecord.getFileUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrl(String fileKey, int expireSeconds) {
|
||||
FileRecordDomain fileRecord = fileRecordRepository.findByFileKey(fileKey);
|
||||
if (fileRecord == null) {
|
||||
throw new RuntimeException("File not found");
|
||||
}
|
||||
|
||||
FileStorageStrategy strategy = storageStrategyMap.values().stream()
|
||||
.filter(s -> s.getStorageType().equals(fileRecord.getStorageType()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("Storage strategy not found"));
|
||||
|
||||
return strategy.generatePresignedUrl(fileKey, expireSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatePresignedUrlByType(String fileKey, String storageType, int expireSeconds, Integer download) {
|
||||
FileStorageStrategy strategy = storageStrategyMap.values().stream()
|
||||
.filter(s -> s.getStorageType().equals(storageType))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new RuntimeException("Storage strategy not found: " + storageType));
|
||||
|
||||
// If download is requested, query database for original filename
|
||||
if (download != null && download == 1) {
|
||||
FileRecordDomain fileRecord = TenantFunctions.callWithIgnoreCheck(() -> fileRecordRepository.findByFileKey(fileKey));
|
||||
if (fileRecord != null && fileRecord.getFileName() != null) {
|
||||
return strategy.generatePresignedUrl(fileKey, expireSeconds, fileRecord.getFileName());
|
||||
}
|
||||
}
|
||||
|
||||
return strategy.generatePresignedUrl(fileKey, expireSeconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-domain</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-spec</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-domain</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.xspaceagi.file.domain.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 文件记录领域模型
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FileRecordDomain {
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private Long tenantId;
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 来源对象类型
|
||||
*/
|
||||
private String targetType;
|
||||
|
||||
/**
|
||||
* 来源对象ID
|
||||
*/
|
||||
private Long targetId;
|
||||
|
||||
/**
|
||||
* 文件名称
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 文件大小
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 文件类型
|
||||
*/
|
||||
private String fileType;
|
||||
|
||||
/**
|
||||
* 文件扩展名
|
||||
*/
|
||||
private String fileExtension;
|
||||
|
||||
/**
|
||||
* 文件元数据
|
||||
*/
|
||||
private String metadata;
|
||||
|
||||
/**
|
||||
* 文件key
|
||||
*/
|
||||
private String fileKey;
|
||||
|
||||
/**
|
||||
* 存储类型
|
||||
*/
|
||||
private String storageType;
|
||||
|
||||
/**
|
||||
* 文件URL
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 是否需要认证访问
|
||||
*/
|
||||
private Boolean authRequired;
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private Date created;
|
||||
|
||||
/**
|
||||
* 修改时间
|
||||
*/
|
||||
private Date modified;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.xspaceagi.file.domain.repository;
|
||||
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件记录仓储接口
|
||||
*/
|
||||
public interface FileRecordRepository {
|
||||
|
||||
/**
|
||||
* 保存文件记录
|
||||
*
|
||||
* @param fileRecord 文件记录
|
||||
* @return 文件记录
|
||||
*/
|
||||
FileRecordDomain save(FileRecordDomain fileRecord);
|
||||
|
||||
/**
|
||||
* 根据ID查询文件记录
|
||||
*
|
||||
* @param id 文件ID
|
||||
* @return 文件记录
|
||||
*/
|
||||
FileRecordDomain findById(Long id);
|
||||
|
||||
/**
|
||||
* 根据文件key查询文件记录
|
||||
*
|
||||
* @param fileKey 文件key
|
||||
* @return 文件记录
|
||||
*/
|
||||
FileRecordDomain findByFileKey(String fileKey);
|
||||
|
||||
/**
|
||||
* 根据租户ID和用户ID查询文件列表
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param userId 用户ID
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<FileRecordDomain> findByTenantIdAndUserId(Long tenantId, Long userId);
|
||||
|
||||
/**
|
||||
* 根据来源对象查询文件列表
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param targetType 来源对象类型
|
||||
* @param targetId 来源对象ID
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<FileRecordDomain> findByTarget(Long tenantId, String targetType, Long targetId);
|
||||
|
||||
/**
|
||||
* 根据存储类型查询文件列表
|
||||
*
|
||||
* @param tenantId 租户ID
|
||||
* @param storageType 存储类型
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<FileRecordDomain> findByStorageType(Long tenantId, String storageType);
|
||||
|
||||
/**
|
||||
* 更新文件记录
|
||||
*
|
||||
* @param fileRecord 文件记录
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean update(FileRecordDomain fileRecord);
|
||||
|
||||
/**
|
||||
* 批量更新文件状态
|
||||
*
|
||||
* @param ids 文件ID列表
|
||||
* @param status 状态
|
||||
* @return 更新数量
|
||||
*/
|
||||
int updateStatusByIds(List<Long> ids, String status);
|
||||
|
||||
/**
|
||||
* 删除文件记录
|
||||
*
|
||||
* @param id 文件ID
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean deleteById(Long id);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.xspaceagi.file.domain.storage;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* 文件存储策略接口
|
||||
*/
|
||||
public interface FileStorageStrategy {
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*
|
||||
* @param inputStream 文件流
|
||||
* @param fileName 文件名
|
||||
* @param contentType 文件类型
|
||||
* @param targetType 业务类型
|
||||
* @return 文件key
|
||||
*/
|
||||
String upload(InputStream inputStream, String fileName, String contentType, String targetType);
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*
|
||||
* @param fileKey 文件key
|
||||
* @return 文件流
|
||||
*/
|
||||
InputStream download(String fileKey);
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* @param fileKey 文件key
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean delete(String fileKey);
|
||||
|
||||
/**
|
||||
* 获取文件访问URL
|
||||
*
|
||||
* @param fileKey 文件key
|
||||
* @return 访问URL
|
||||
*/
|
||||
String getFileUrl(String fileKey);
|
||||
|
||||
/**
|
||||
* 生成签名URL(用于云存储)
|
||||
*
|
||||
* @param fileKey 文件key
|
||||
* @param expireSeconds 过期时间(秒)
|
||||
* @return 签名URL,如果不支持则返回null
|
||||
*/
|
||||
default String generatePresignedUrl(String fileKey, int expireSeconds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名URL(用于云存储),支持指定下载文件名
|
||||
*
|
||||
* @param fileKey 文件key
|
||||
* @param expireSeconds 过期时间(秒)
|
||||
* @param fileName 下载时的文件名
|
||||
* @return 签名URL,如果不支持则返回null
|
||||
*/
|
||||
default String generatePresignedUrl(String fileKey, int expireSeconds, String fileName) {
|
||||
return generatePresignedUrl(fileKey, expireSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储类型
|
||||
*
|
||||
* @return 存储类型
|
||||
*/
|
||||
String getStorageType();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-infra</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-domain</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-sdk</artifactId>
|
||||
</dependency>
|
||||
<!-- 腾讯云 COS -->
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos_api</artifactId>
|
||||
</dependency>
|
||||
<!-- 阿里云 OSS -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
</dependency>
|
||||
<!-- AWS S3 -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-sdk</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.xspaceagi.file.sdk;
|
||||
|
||||
public interface IFileAccessService {
|
||||
|
||||
String getFileUrlWithAk(String fileUrl);
|
||||
|
||||
String getFileUrlWithAk(String fileUrl, boolean returnOriginalUrl);
|
||||
|
||||
void checkFileUrlAk(String uri, String ak);
|
||||
|
||||
void checkFileUrlAk0(String uri, String ak);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-spec</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-spec</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.xspaceagi.file.spec.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 存储类型枚举
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum StorageTypeEnum {
|
||||
|
||||
/**
|
||||
* 腾讯云COS
|
||||
*/
|
||||
COS("cos", "腾讯云COS"),
|
||||
|
||||
/**
|
||||
* 阿里云OSS
|
||||
*/
|
||||
OSS("oss", "阿里云OSS"),
|
||||
|
||||
/**
|
||||
* S3协议
|
||||
*/
|
||||
S3("s3", "S3协议"),
|
||||
|
||||
/**
|
||||
* 本地存储
|
||||
*/
|
||||
FILE("file", "本地存储");
|
||||
|
||||
private final String code;
|
||||
private final String desc;
|
||||
|
||||
public static StorageTypeEnum fromCode(String code) {
|
||||
for (StorageTypeEnum type : values()) {
|
||||
if (type.getCode().equals(code)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return FILE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-web</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.deploy.skip>true</maven.deploy.skip>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-application</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-web</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,400 @@
|
||||
package com.xspaceagi.file.web.controller;
|
||||
|
||||
import com.xspaceagi.file.application.service.FileManagementService;
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
import com.xspaceagi.file.sdk.IFileAccessService;
|
||||
import com.xspaceagi.file.web.dto.FileRecordVO;
|
||||
import com.xspaceagi.system.sdk.service.UserAccessKeyApiService;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
|
||||
import com.xspaceagi.system.spec.enums.HttpStatusEnum;
|
||||
import com.xspaceagi.system.spec.exception.BizException;
|
||||
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
|
||||
import com.xspaceagi.system.web.controller.ChatKeyCheck;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
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 org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* File Management Controller
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "File Management", description = "File upload, download, delete operations")
|
||||
public class FileManagementController {
|
||||
|
||||
private final FileManagementService fileManagementService;
|
||||
|
||||
private final UserAccessKeyApiService userAccessKeyApiService;
|
||||
|
||||
private final IFileAccessService iFileAccessService;
|
||||
|
||||
@PostMapping("/file/upload")
|
||||
@Operation(summary = "Upload file")
|
||||
public ReqResult<FileRecordVO> uploadFile(HttpServletRequest request, @RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "targetType", required = false) String targetType,
|
||||
@RequestParam(value = "targetId", required = false, defaultValue = "-1") Long targetId,
|
||||
@RequestParam(value = "metadata", required = false) String metadata) {
|
||||
if (!RequestContext.get().isLogin()) {
|
||||
ChatKeyCheck.check(request, userAccessKeyApiService);
|
||||
}
|
||||
boolean isAuthRequired = true;
|
||||
// If targetType or targetId not provided, try to parse from Referer
|
||||
if (targetType == null || targetId == null) {
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
TargetInfo targetInfo = parseTargetFromReferer(referer);
|
||||
if (targetInfo != null) {
|
||||
if (targetType == null) {
|
||||
targetType = targetInfo.getType();
|
||||
}
|
||||
if (targetId == null) {
|
||||
targetId = targetInfo.getId();
|
||||
}
|
||||
} else {
|
||||
targetType = "default";
|
||||
}
|
||||
|
||||
// Check if authentication is required: match public path patterns
|
||||
isAuthRequired = !isPublicPath(referer);
|
||||
}
|
||||
}
|
||||
FileRecordDomain fileRecord = fileManagementService.uploadFile(file, RequestContext.get().getTenantId(),
|
||||
RequestContext.get().getUserId(), targetType, targetId, metadata, isAuthRequired);
|
||||
|
||||
if (!RequestContext.get().isLogin()) {
|
||||
String fileUrl = iFileAccessService.getFileUrlWithAk(fileRecord.getFileUrl());
|
||||
fileRecord.setFileUrl(fileUrl);
|
||||
}
|
||||
|
||||
return ReqResult.success(toVO(fileRecord));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is public (no authentication required)
|
||||
* Public paths include:
|
||||
* - /space/{tenantId}/agent/{agentId}
|
||||
* - /system/config/setting
|
||||
* - /space/{tenantId}/app-dev/{appId}
|
||||
* - /space/{tenantId}/workflow/{workflowId}
|
||||
* - /space/{tenantId}/plugin/{pluginId}
|
||||
* - /space/{tenantId}/skill-details/{skillId}
|
||||
* - /space/{tenantId}/mcp/edit/{mcpId}
|
||||
*/
|
||||
private boolean isPublicPath(String referer) {
|
||||
if (referer == null || referer.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String path = extractPath(referer);
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Define public path regex patterns
|
||||
String[] publicPatterns = {
|
||||
"/space/\\d+/agent/\\d+",
|
||||
"/system/config/setting",
|
||||
"/space/\\d+/app-dev/\\d+",
|
||||
"/space/\\d+/workflow/\\d+",
|
||||
"/space/\\d+/plugin/\\d+",
|
||||
"/space/\\d+/skill-details/\\d+",
|
||||
"/space/\\d+/mcp/edit/\\d+"
|
||||
};
|
||||
|
||||
for (String pattern : publicPatterns) {
|
||||
if (Pattern.matches(pattern, path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse targetType and targetId from Referer URL
|
||||
* Supported URL formats:
|
||||
* - /home/chat/{targetId} -> targetType=agent
|
||||
* - /space/knowledge/{targetId} -> targetType=knowledge
|
||||
* - Other paths -> targetType=other
|
||||
*/
|
||||
private TargetInfo parseTargetFromReferer(String referer) {
|
||||
if (referer == null || referer.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract path part (remove protocol, domain, query params)
|
||||
String path = extractPath(referer);
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use regex to match path patterns
|
||||
TargetInfo result = matchPathPattern(path, "/home/chat/(\\d+)/(\\d+)", "agent", 2);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = matchPathPattern(path, "/agent/(\\d+)", "agent", 1);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = matchPathPattern(path, "/app/(\\d+)", "agent", 1);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = matchPathPattern(path, "/space/(\\d+)/knowledge/(\\d+)", "knowledge", 2);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = matchPathPattern(path, "/space/(\\d+)/workflow/(\\d+)", "workflow", 2);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = matchPathPattern(path, "/space/(\\d+)/plugin/(\\d+)", "plugin", 2);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = matchPathPattern(path, "/space/(\\d+)/skill-details/(\\d+)", "skill", 2);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = matchPathPattern(path, "/space/(\\d+)/mcp/edit/(\\d+)", "mcp", 2);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return matchPathPattern(path, "/space/(\\d+)/app-dev/(\\d+)", "app-dev", 2);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse Referer: {}", referer, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path part from URL
|
||||
*/
|
||||
private String extractPath(String url) {
|
||||
try {
|
||||
// Remove query params and anchor
|
||||
int queryIndex = url.indexOf('?');
|
||||
if (queryIndex > 0) {
|
||||
url = url.substring(0, queryIndex);
|
||||
}
|
||||
int anchorIndex = url.indexOf('#');
|
||||
if (anchorIndex > 0) {
|
||||
url = url.substring(0, anchorIndex);
|
||||
}
|
||||
|
||||
// Remove protocol and domain
|
||||
int pathStart = url.indexOf("://");
|
||||
if (pathStart > 0) {
|
||||
pathStart = url.indexOf('/', pathStart + 3);
|
||||
if (pathStart > 0) {
|
||||
return url.substring(pathStart);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match path pattern using regex
|
||||
*/
|
||||
private TargetInfo matchPathPattern(String path, String regex, String targetType, int index) {
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(regex);
|
||||
Matcher matcher = pattern.matcher(path);
|
||||
if (matcher.find()) {
|
||||
Long id = Long.parseLong(matcher.group(index));
|
||||
return new TargetInfo(targetType, id);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to match path pattern: pattern={}, path={}", regex, path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract last number from path as ID
|
||||
*/
|
||||
private TargetInfo extractLastNumber(String path) {
|
||||
String[] segments = path.split("/");
|
||||
for (int i = segments.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
Long id = Long.parseLong(segments[i]);
|
||||
return new TargetInfo("other", id);
|
||||
} catch (NumberFormatException e) {
|
||||
// Continue trying previous segment
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Target info inner class
|
||||
*/
|
||||
@Data
|
||||
private static class TargetInfo {
|
||||
private final String type;
|
||||
private final Long id;
|
||||
|
||||
public TargetInfo(String type, Long id) {
|
||||
this.type = type;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access file by fileKey
|
||||
* Local storage: return file stream directly
|
||||
* Cloud storage (s3/cos/oss): 302 redirect to presigned URL
|
||||
*/
|
||||
@GetMapping("/f/**")
|
||||
@Operation(summary = "Access file by fileKey")
|
||||
public ResponseEntity<?> getFileByPath(HttpServletRequest request, @RequestParam(name = "download", required = false) Integer download) {
|
||||
String fullPath = request.getRequestURI();
|
||||
String fileKey = fullPath.substring("/api/f/".length());
|
||||
FileRecordDomain fileRecord = fileManagementService.getFileByKey(fileKey);
|
||||
boolean authRequired = fileRecord == null || !Boolean.FALSE.equals(fileRecord.getAuthRequired());
|
||||
|
||||
if (!RequestContext.get().isLogin() && authRequired) {
|
||||
try {
|
||||
iFileAccessService.checkFileUrlAk0(request.getRequestURI(), request.getParameter("ak"));
|
||||
} catch (Exception e) {
|
||||
throw BizException.of(HttpStatusEnum.UNAUTHORIZED, ErrorCodeEnum.UNAUTHORIZED,
|
||||
BizExceptionCodeEnum.systemUnauthorizedOrSessionExpired);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Accessing file: {}", fileKey);
|
||||
|
||||
try {
|
||||
// Extract storage type from fileKey (format: /storageType/businessType/date/uuid.ext)
|
||||
String storageType = extractStorageTypeFromFileKey(fileKey);
|
||||
if (storageType == null) {
|
||||
log.error("Cannot extract storage type from fileKey: {}", fileKey);
|
||||
return ResponseEntity.badRequest().body("Invalid file key format");
|
||||
}
|
||||
|
||||
// Check storage type
|
||||
if ("local".equals(storageType)) {
|
||||
// Local storage: query database for file info, then return file stream
|
||||
if (fileRecord == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
InputStream inputStream = fileManagementService.downloadFile(fileKey);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(
|
||||
fileRecord.getFileType() != null ? fileRecord.getFileType() : "application/octet-stream"));
|
||||
if (download != null && download == 1) {
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(fileRecord.getFileName(), StandardCharsets.UTF_8) + "\"");
|
||||
} else {
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + URLEncoder.encode(fileRecord.getFileName(), StandardCharsets.UTF_8) + "\"");
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.body(new InputStreamResource(inputStream));
|
||||
} else {
|
||||
// Cloud storage (s3/cos/oss): generate presigned URL and 302 redirect, no database query needed
|
||||
String signedUrl = fileManagementService.generatePresignedUrlByType(fileKey, storageType, 3600, download); // 1 hour validity
|
||||
if (signedUrl == null) {
|
||||
log.error("Failed to generate presigned URL: storageType={}, fileKey={}", storageType, fileKey);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to generate presigned URL");
|
||||
}
|
||||
|
||||
log.info("302 redirect to presigned URL: storageType={}, fileKey={}", storageType, fileKey);
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.location(URI.create(signedUrl))
|
||||
.build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("File access failed: {}", fileKey, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract storage type from fileKey
|
||||
* fileKey format: /storageType/businessType/date/uuid.ext
|
||||
* Example: /s3/agent/20260417/xxx.jpg -> s3
|
||||
*/
|
||||
private String extractStorageTypeFromFileKey(String fileKey) {
|
||||
if (fileKey == null || fileKey.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove leading slash
|
||||
String key = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
|
||||
|
||||
// Extract first segment as storage type
|
||||
int firstSlash = key.indexOf('/');
|
||||
if (firstSlash > 0) {
|
||||
return key.substring(0, firstSlash);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static FileRecordVO toVO(FileRecordDomain domain) {
|
||||
return FileRecordVO.builder()
|
||||
.id(domain.getId())
|
||||
.tenantId(domain.getTenantId())
|
||||
.userId(domain.getUserId())
|
||||
.targetType(domain.getTargetType())
|
||||
.targetId(domain.getTargetId())
|
||||
.fileName(domain.getFileName())
|
||||
.size(domain.getFileSize())
|
||||
.mimeType(domain.getFileType())
|
||||
.fileExtension(domain.getFileExtension())
|
||||
.metadata(domain.getMetadata())
|
||||
.key(domain.getFileKey())
|
||||
.storageType(domain.getStorageType())
|
||||
.url(domain.getFileUrl())
|
||||
.authRequired(domain.getAuthRequired())
|
||||
.status(domain.getStatus())
|
||||
.created(domain.getCreated())
|
||||
.modified(domain.getModified())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.xspaceagi.file.web.controller.api;
|
||||
|
||||
import com.xspaceagi.agent.core.adapter.application.PublishApplicationService;
|
||||
import com.xspaceagi.agent.core.adapter.dto.PublishedPermissionDto;
|
||||
import com.xspaceagi.agent.core.adapter.repository.entity.Published;
|
||||
import com.xspaceagi.file.application.service.FileManagementService;
|
||||
import com.xspaceagi.file.domain.model.FileRecordDomain;
|
||||
import com.xspaceagi.file.sdk.IFileAccessService;
|
||||
import com.xspaceagi.file.web.dto.FileRecordVO;
|
||||
import com.xspaceagi.system.spec.common.RequestContext;
|
||||
import com.xspaceagi.system.spec.dto.ReqResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static com.xspaceagi.file.web.controller.FileManagementController.toVO;
|
||||
|
||||
@Tag(name = "文件上传")
|
||||
@Slf4j
|
||||
@RestController
|
||||
public class FileApiController {
|
||||
|
||||
@Resource
|
||||
private IFileAccessService iFileAccessService;
|
||||
|
||||
@Resource
|
||||
private FileManagementService fileManagementService;
|
||||
|
||||
@Resource
|
||||
private PublishApplicationService publishApplicationService;
|
||||
;
|
||||
|
||||
@Operation(summary = "文件上传接口", description = "文件上传接口,返回文件网络地址")
|
||||
@PostMapping("/api/v1/file/upload")
|
||||
public ReqResult<FileRecordVO> uploadFile(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "targetType", required = false, defaultValue = "Default") String targetType,
|
||||
@RequestParam(value = "targetId", required = false, defaultValue = "-1") Long targetId,
|
||||
@RequestParam(value = "metadata", required = false) String metadata) {
|
||||
|
||||
if ("Agent".equals(targetType) && targetId > 0) {
|
||||
PublishedPermissionDto publishedPermissionDto = publishApplicationService.hasPermission(Published.TargetType.Agent, targetId);
|
||||
if (!publishedPermissionDto.isView()) {
|
||||
return ReqResult.error("No permission to upload file for this agent id");
|
||||
}
|
||||
}
|
||||
|
||||
FileRecordDomain fileRecord = fileManagementService.uploadFile(file, RequestContext.get().getTenantId(),
|
||||
RequestContext.get().getUserId(), targetType, targetId, metadata, true);
|
||||
|
||||
return ReqResult.success(toVO(fileRecord));
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/file/ak")
|
||||
public ReqResult<String> fileAk(@RequestParam(name = "fileUrl") String fileUrl) {
|
||||
fileUrl = URLDecoder.decode(fileUrl, StandardCharsets.UTF_8);
|
||||
//fileUrl中移除ak参数
|
||||
fileUrl = fileUrl.replaceAll("([?&])ak(?:=[^&]*)?&?", "$1").replaceAll("\\?$", "");
|
||||
return ReqResult.success(iFileAccessService.getFileUrlWithAk(fileUrl, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.xspaceagi.file.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 文件记录VO
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "文件记录")
|
||||
public class FileRecordVO {
|
||||
|
||||
@Schema(description = "文件ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "来源对象类型")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "来源对象ID")
|
||||
private Long targetId;
|
||||
|
||||
@Schema(description = "文件名称")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "文件大小(字节)")
|
||||
private Long size;
|
||||
|
||||
@Schema(description = "文件类型")
|
||||
private String mimeType;
|
||||
|
||||
@Schema(description = "文件扩展名")
|
||||
private String fileExtension;
|
||||
|
||||
@Schema(description = "文件元数据(JSON格式)")
|
||||
private String metadata;
|
||||
|
||||
@Schema(description = "文件存储标识key")
|
||||
private String key;
|
||||
|
||||
@Schema(description = "存储方式")
|
||||
private String storageType;
|
||||
|
||||
@Schema(description = "文件访问URL")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "是否需要认证访问")
|
||||
private Boolean authRequired;
|
||||
|
||||
@Schema(description = "文件状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date created;
|
||||
|
||||
@Schema(description = "修改时间")
|
||||
private Date modified;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.xspaceagi.file.web.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 文件上传请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "文件上传请求")
|
||||
public class FileUploadRequest {
|
||||
|
||||
@Schema(description = "租户ID", required = true)
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "用户ID", required = true)
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "来源对象类型")
|
||||
private String targetType;
|
||||
|
||||
@Schema(description = "来源对象ID")
|
||||
private Long targetId;
|
||||
|
||||
@Schema(description = "文件元数据(JSON格式)")
|
||||
private String metadata;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?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">
|
||||
<parent>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-modules</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>app-platform-file</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
<properties/>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>system-sdk</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-application</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-infra</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-domain</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-spec</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-api</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-sdk</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xspaceagi</groupId>
|
||||
<artifactId>app-platform-file-web</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<modules>
|
||||
<module>app-platform-file-application</module>
|
||||
<module>app-platform-file-domain</module>
|
||||
<module>app-platform-file-infra</module>
|
||||
<module>app-platform-file-spec</module>
|
||||
<module>app-platform-file-api</module>
|
||||
<module>app-platform-file-web</module>
|
||||
<module>app-platform-file-sdk</module>
|
||||
</modules>
|
||||
</project>
|
||||
Reference in New Issue
Block a user