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-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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user