chore: initialize qiming workspace repository

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
package com.xspaceagi.file.infra.config;
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 文件存储配置
*/
@Configuration
public class FileStorageConfig {
@Bean
public Map<String, FileStorageStrategy> storageStrategyMap(List<FileStorageStrategy> strategies) {
return strategies.stream()
.collect(Collectors.toMap(
FileStorageStrategy::getStorageType,
strategy -> strategy
));
}
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.file.infra.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis 配置
*/
@Configuration
@MapperScan("com.xspaceagi.file.infra.dao.mapper")
public class MyBatisConfig {
}

View File

@@ -0,0 +1,109 @@
package com.xspaceagi.file.infra.dao.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.util.Date;
/**
* 文件记录实体
* @TableName file_record
*/
@TableName(value = "file_record")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class FileRecord {
/**
* 主键ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 租户ID
*/
@TableField(value = "_tenant_id")
private Long tenantId;
/**
* 用户ID
*/
private Long userId;
/**
* 来源对象类型agent、knowledge、message等
*/
private String targetType;
/**
* 来源对象ID
*/
private Long targetId;
/**
* 文件名称
*/
private String fileName;
/**
* 文件大小(字节)
*/
private Long fileSize;
/**
* 文件类型MIME类型
*/
private String fileType;
/**
* 文件扩展名
*/
private String fileExtension;
/**
* 文件元数据JSON格式存储图片宽高、视频时长等
*/
private String metadata;
/**
* 文件存储标识key
*/
private String fileKey;
/**
* 存储方式cos:腾讯云COS, oss:阿里云OSS, s3:S3协议, file:本地存储)
*/
private String storageType;
/**
* 文件访问URL
*/
private String fileUrl;
/**
* 是否需要认证访问0:公开, 1:需要认证)
*/
private Boolean authRequired;
/**
* 文件状态active:正常, deleted:已删除)
*/
private String status;
/**
* 创建时间
*/
private Date created;
/**
* 修改时间
*/
private Date modified;
}

View File

@@ -0,0 +1,63 @@
package com.xspaceagi.file.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.file.infra.dao.entity.FileRecord;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 文件记录Mapper
*
* @description 针对表【file_record(文件记录表)】的数据库操作Mapper
* @Entity com.xspaceagi.file.infra.dao.entity.FileRecord
*/
public interface FileRecordMapper extends BaseMapper<FileRecord> {
/**
* 根据租户ID和用户ID查询文件列表
*
* @param tenantId 租户ID
* @param userId 用户ID
* @return 文件列表
*/
List<FileRecord> findByTenantIdAndUserId(@Param("tenantId") Long tenantId, @Param("userId") Long userId);
/**
* 根据来源对象查询文件列表
*
* @param tenantId 租户ID
* @param targetType 来源对象类型
* @param targetId 来源对象ID
* @return 文件列表
*/
List<FileRecord> findByTarget(@Param("tenantId") Long tenantId,
@Param("targetType") String targetType,
@Param("targetId") Long targetId);
/**
* 根据文件key查询文件
*
* @param fileKey 文件key
* @return 文件记录
*/
FileRecord findByFileKey(@Param("fileKey") String fileKey);
/**
* 根据存储类型查询文件列表
*
* @param tenantId 租户ID
* @param storageType 存储类型
* @return 文件列表
*/
List<FileRecord> findByStorageType(@Param("tenantId") Long tenantId, @Param("storageType") String storageType);
/**
* 批量更新文件状态
*
* @param ids 文件ID列表
* @param status 状态
* @return 更新数量
*/
int updateStatusByIds(@Param("ids") List<Long> ids, @Param("status") String status);
}

View File

@@ -0,0 +1,88 @@
package com.xspaceagi.file.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xspaceagi.file.domain.model.FileRecordDomain;
import com.xspaceagi.file.domain.repository.FileRecordRepository;
import com.xspaceagi.file.infra.dao.entity.FileRecord;
import com.xspaceagi.file.infra.dao.mapper.FileRecordMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.stream.Collectors;
/**
* 文件记录仓储实现
*/
@Repository
@RequiredArgsConstructor
public class FileRecordRepositoryImpl implements FileRecordRepository {
private final FileRecordMapper fileRecordMapper;
@Override
public FileRecordDomain save(FileRecordDomain fileRecord) {
FileRecord entity = toEntity(fileRecord);
fileRecordMapper.insert(entity);
return toDomain(entity);
}
@Override
public FileRecordDomain findById(Long id) {
FileRecord entity = fileRecordMapper.selectById(id);
return entity != null ? toDomain(entity) : null;
}
@Override
public FileRecordDomain findByFileKey(String fileKey) {
FileRecord entity = fileRecordMapper.findByFileKey(fileKey);
return entity != null ? toDomain(entity) : null;
}
@Override
public List<FileRecordDomain> findByTenantIdAndUserId(Long tenantId, Long userId) {
List<FileRecord> entities = fileRecordMapper.findByTenantIdAndUserId(tenantId, userId);
return entities.stream().map(this::toDomain).collect(Collectors.toList());
}
@Override
public List<FileRecordDomain> findByTarget(Long tenantId, String targetType, Long targetId) {
List<FileRecord> entities = fileRecordMapper.findByTarget(tenantId, targetType, targetId);
return entities.stream().map(this::toDomain).collect(Collectors.toList());
}
@Override
public List<FileRecordDomain> findByStorageType(Long tenantId, String storageType) {
List<FileRecord> entities = fileRecordMapper.findByStorageType(tenantId, storageType);
return entities.stream().map(this::toDomain).collect(Collectors.toList());
}
@Override
public boolean update(FileRecordDomain fileRecord) {
FileRecord entity = toEntity(fileRecord);
return fileRecordMapper.updateById(entity) > 0;
}
@Override
public int updateStatusByIds(List<Long> ids, String status) {
return fileRecordMapper.updateStatusByIds(ids, status);
}
@Override
public boolean deleteById(Long id) {
return fileRecordMapper.deleteById(id) > 0;
}
private FileRecordDomain toDomain(FileRecord entity) {
FileRecordDomain domain = new FileRecordDomain();
BeanUtils.copyProperties(entity, domain);
return domain;
}
private FileRecord toEntity(FileRecordDomain domain) {
FileRecord entity = new FileRecord();
BeanUtils.copyProperties(domain, entity);
return entity;
}
}

View File

@@ -0,0 +1,177 @@
package com.xspaceagi.file.infra.storage;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.auth.COSCredentials;
import com.qcloud.cos.http.HttpMethodName;
import com.qcloud.cos.model.ObjectMetadata;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.region.Region;
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
/**
* Tencent Cloud COS Storage Implementation
*/
@Slf4j
@Component
//@ConditionalOnProperty(name = "storage.type", havingValue = "cos")
public class CosFileStorageStrategy implements FileStorageStrategy {
@Value("${cos.secret-id:}")
private String secretId;
@Value("${cos.secret-key:}")
private String secretKey;
@Value("${cos.region:ap-guangzhou}")
private String region;
@Value("${cos.bucket-name:}")
private String bucketName;
@Value("${cos.base-url:}")
private String baseUrl;
@Value("${cos.endpoint:}")
private String endpoint;
private volatile COSClient cosClient;
private COSClient getCOSClient() {
if (cosClient == null) {
synchronized (this) {
if (cosClient == null) {
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
// If custom endpoint is configured, use UserSpecifiedEndpointBuilder
if (endpoint != null && !endpoint.isEmpty()) {
com.qcloud.cos.endpoint.UserSpecifiedEndpointBuilder endpointBuilder =
new com.qcloud.cos.endpoint.UserSpecifiedEndpointBuilder(endpoint, endpoint);
ClientConfig clientConfig = new ClientConfig(new Region(region));
clientConfig.setEndpointBuilder(endpointBuilder);
cosClient = new COSClient(cred, clientConfig);
} else {
ClientConfig clientConfig = new ClientConfig(new Region(region));
cosClient = new COSClient(cred, clientConfig);
}
}
}
}
return cosClient;
}
@Override
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
try {
COSClient client = getCOSClient();
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(contentType);
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileKey, inputStream, metadata);
client.putObject(putObjectRequest);
log.info("File uploaded to COS successfully: {}", fileKey);
return fileKey;
} catch (Exception e) {
log.error("Failed to upload file to COS", e);
throw new RuntimeException("File upload failed", e);
}
}
@Override
public InputStream download(String fileKey) {
try {
COSClient client = getCOSClient();
return client.getObject(bucketName, fileKey).getObjectContent();
} catch (Exception e) {
log.error("Failed to download file from COS: {}", fileKey, e);
throw new RuntimeException("File download failed", e);
}
}
@Override
public boolean delete(String fileKey) {
try {
COSClient client = getCOSClient();
client.deleteObject(bucketName, fileKey);
log.info("File deleted from COS successfully: {}", fileKey);
return true;
} catch (Exception e) {
log.error("Failed to delete file from COS: {}", fileKey, e);
return false;
}
}
@Override
public String getFileUrl(String fileKey) {
return fileKey;
}
@Override
public String generatePresignedUrl(String fileKey, int expireSeconds) {
return generatePresignedUrl(fileKey, expireSeconds, null);
}
@Override
public String generatePresignedUrl(String fileKey, int expireSeconds, String fileName) {
try {
COSClient client = getCOSClient();
// Remove leading slash from fileKey if present
String objectKey = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
// Generate presigned URL
java.util.Date expirationDate = new java.util.Date(System.currentTimeMillis() + expireSeconds * 1000L);
// Set response headers if fileName is provided
java.util.Map<String, String> responseHeaders = new java.util.HashMap<>();
if (fileName != null && !fileName.isEmpty()) {
responseHeaders.put("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + "\"");
}
java.net.URL url = client.generatePresignedUrl(bucketName, objectKey, expirationDate, HttpMethodName.GET, new HashMap<>(), responseHeaders, false, false);
String signedUrl = url.toString();
// If custom baseUrl is configured, replace default COS domain
if (baseUrl != null && !baseUrl.isEmpty()) {
try {
java.net.URL originalUrl = new java.net.URL(signedUrl);
String path = originalUrl.getPath();
String query = originalUrl.getQuery();
// Remove trailing slash from baseUrl if present
String normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
// Build new URL: baseUrl + path + query
signedUrl = normalizedBaseUrl + path + (query != null ? "?" + query : "");
} catch (Exception e) {
log.warn("Failed to replace baseUrl, using original URL: {}", e.getMessage());
}
}
log.info("COS presigned URL generated successfully: {}", fileKey);
return signedUrl;
} catch (Exception e) {
log.error("Failed to generate COS presigned URL: {}", fileKey, e);
return null;
}
}
@Override
public String getStorageType() {
return "cos";
}
}

View File

@@ -0,0 +1,49 @@
package com.xspaceagi.file.infra.storage;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* File Key Generator Utility
*/
public class FileKeyGenerator {
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
/**
* Generate file key
* Format: /storageType/businessType/date/uuid.ext
* Example: /s3/agent/20260417/a1b2c3d4.jpg
*
* @param fileName Original file name
* @param targetType Business type
* @param storageType Storage type
* @return File key
*/
public static String generate(String fileName, String targetType, String storageType) {
String extension = extractExtension(fileName);
String date = LocalDate.now().format(DATE_FORMATTER);
String uuid = UUID.randomUUID().toString().replace("-", "");
String businessType = targetType != null ? targetType.toLowerCase() : "default";
return String.format("%s/%s/%s/%s%s", storageType, businessType, date, uuid, extension);
}
/**
* Extract file extension
*
* @param fileName File name
* @return Extension (including dot), or empty string if no extension
*/
private static String extractExtension(String fileName) {
if (fileName == null || fileName.isEmpty()) {
return "";
}
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex > 0 && dotIndex < fileName.length() - 1) {
return fileName.substring(dotIndex);
}
return "";
}
}

View File

@@ -0,0 +1,79 @@
package com.xspaceagi.file.infra.storage;
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
/**
* Local File Storage Implementation
*/
@Slf4j
@Component
public class LocalFileStorageStrategy implements FileStorageStrategy {
@Value("${local.upload.folder:}")
private String uploadFolder;
@Value("${file.uploadFolder:/tmp/uploads}")
private String oldUploadFolder;
@Override
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
try {
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
Path targetPath = Paths.get(StringUtils.isBlank(uploadFolder) ? oldUploadFolder : uploadFolder, fileKey);
Files.createDirectories(targetPath.getParent());
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
log.info("File uploaded successfully: {}", fileKey);
return fileKey;
} catch (IOException e) {
log.error("File upload failed", e);
throw new RuntimeException("File upload failed", e);
}
}
@Override
public InputStream download(String fileKey) {
try {
Path filePath = Paths.get(StringUtils.isBlank(uploadFolder) ? oldUploadFolder : uploadFolder, fileKey);
if (!Files.exists(filePath)) {
throw new FileNotFoundException("File not found: " + fileKey);
}
return Files.newInputStream(filePath);
} catch (IOException e) {
log.error("File download failed: {}", fileKey, e);
throw new RuntimeException("File download failed", e);
}
}
@Override
public boolean delete(String fileKey) {
try {
Path filePath = Paths.get(StringUtils.isBlank(uploadFolder) ? oldUploadFolder : uploadFolder, fileKey);
return Files.deleteIfExists(filePath);
} catch (IOException e) {
log.error("File deletion failed: {}", fileKey, e);
return false;
}
}
@Override
public String getFileUrl(String fileKey) {
return fileKey;
}
@Override
public String getStorageType() {
return "local";
}
}

View File

@@ -0,0 +1,158 @@
package com.xspaceagi.file.infra.storage;
import com.aliyun.oss.ClientBuilderConfiguration;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.common.auth.CredentialsProvider;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.comm.SignVersion;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest;
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* Alibaba Cloud OSS Storage Implementation
*/
@Slf4j
@Component
//@ConditionalOnProperty(name = "storage.type", havingValue = "oss")
public class OssFileStorageStrategy implements FileStorageStrategy {
@Value("${oss.endpoint:}")
private String endpoint;
@Value("${oss.access-key-id:}")
private String accessKeyId;
@Value("${oss.access-key-secret:}")
private String accessKeySecret;
@Value("${oss.region-name:cn-hangzhou}")
private String regionName;
@Value("${oss.bucket-name:}")
private String bucketName;
private volatile OSS ossClient;
private OSS getOSSClient() {
if (ossClient == null) {
synchronized (this) {
if (ossClient == null) {
CredentialsProvider credentialsProvider = CredentialsProviderFactory.newDefaultCredentialProvider(accessKeyId, accessKeySecret);
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSupportCname(true);
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(regionName)
.build();
}
}
}
return ossClient;
}
@Override
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
try {
OSS client = getOSSClient();
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(contentType);
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, fileKey, inputStream, metadata);
client.putObject(putObjectRequest);
log.info("File uploaded to OSS successfully: {}", fileKey);
return fileKey;
} catch (Exception e) {
log.error("Failed to upload file to OSS", e);
throw new RuntimeException("File upload failed", e);
}
}
@Override
public InputStream download(String fileKey) {
try {
OSS client = getOSSClient();
return client.getObject(bucketName, fileKey).getObjectContent();
} catch (Exception e) {
log.error("Failed to download file from OSS: {}", fileKey, e);
throw new RuntimeException("File download failed", e);
}
}
@Override
public boolean delete(String fileKey) {
try {
OSS client = getOSSClient();
client.deleteObject(bucketName, fileKey);
log.info("File deleted from OSS successfully: {}", fileKey);
return true;
} catch (Exception e) {
log.error("Failed to delete file from OSS: {}", fileKey, e);
return false;
}
}
@Override
public String getFileUrl(String fileKey) {
return fileKey;
}
@Override
public String generatePresignedUrl(String fileKey, int expireSeconds) {
return generatePresignedUrl(fileKey, expireSeconds, null);
}
@Override
public String generatePresignedUrl(String fileKey, int expireSeconds, String fileName) {
try {
OSS client = getOSSClient();
// Remove leading slash from fileKey if present
String objectKey = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
// Generate presigned URL
java.util.Date expirationDate = new java.util.Date(System.currentTimeMillis() + expireSeconds * 1000L);
// Create GeneratePresignedUrlRequest
com.aliyun.oss.model.GeneratePresignedUrlRequest request =
new com.aliyun.oss.model.GeneratePresignedUrlRequest(bucketName, objectKey);
request.setExpiration(expirationDate);
// Add Content-Disposition header if fileName is provided
if (fileName != null && !fileName.isEmpty()) {
com.aliyun.oss.model.ResponseHeaderOverrides responseHeaders =
new com.aliyun.oss.model.ResponseHeaderOverrides();
responseHeaders.setContentDisposition("attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + "\"");
request.setResponseHeaders(responseHeaders);
}
java.net.URL url = client.generatePresignedUrl(request);
log.info("OSS presigned URL generated successfully: {}", fileKey);
return url.toString();
} catch (Exception e) {
log.error("Failed to generate OSS presigned URL: {}", fileKey, e);
return null;
}
}
@Override
public String getStorageType() {
return "oss";
}
}

View File

@@ -0,0 +1,197 @@
package com.xspaceagi.file.infra.storage;
import com.xspaceagi.file.domain.storage.FileStorageStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import java.io.InputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* S3 Protocol Storage Implementation
*/
@Slf4j
@Component
//@ConditionalOnProperty(name = "storage.type", havingValue = "s3")
public class S3FileStorageStrategy implements FileStorageStrategy {
@Value("${s3.endpoint:}")
private String endpoint;
@Value("${s3.access-key:}")
private String accessKey;
@Value("${s3.secret-key:}")
private String secretKey;
@Value("${s3.bucket-name:}")
private String bucketName;
@Value("${s3.region:us-east-1}")
private String region;
private volatile S3Client s3Client;
private volatile S3Presigner s3Presigner;
private S3Client getS3Client() {
if (s3Client == null) {
synchronized (this) {
if (s3Client == null) {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
s3Client = S3Client.builder()
.endpointOverride(URI.create(endpoint))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.region(Region.of(region))
.forcePathStyle(true)
.build();
}
}
}
return s3Client;
}
private S3Presigner getS3Presigner() {
if (s3Presigner == null) {
synchronized (this) {
if (s3Presigner == null) {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
software.amazon.awssdk.services.s3.presigner.S3Presigner.Builder builder = S3Presigner.builder()
.endpointOverride(URI.create(endpoint))
.credentialsProvider(StaticCredentialsProvider.create(credentials))
.region(Region.of(region));
// Configure path-style access for presigner
builder.serviceConfiguration(
software.amazon.awssdk.services.s3.S3Configuration.builder()
.pathStyleAccessEnabled(true)
.build()
);
s3Presigner = builder.build();
}
}
}
return s3Presigner;
}
@Override
public String upload(InputStream inputStream, String fileName, String contentType, String targetType) {
try {
S3Client client = getS3Client();
String fileKey = FileKeyGenerator.generate(fileName, targetType, getStorageType());
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(fileKey)
.contentType(contentType)
.build();
client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, inputStream.available()));
log.info("File uploaded to S3 successfully: {}", fileKey);
return fileKey;
} catch (Exception e) {
log.error("Failed to upload file to S3", e);
throw new RuntimeException("File upload failed", e);
}
}
@Override
public InputStream download(String fileKey) {
try {
S3Client client = getS3Client();
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(fileKey)
.build();
return client.getObject(getObjectRequest);
} catch (Exception e) {
log.error("Failed to download file from S3: {}", fileKey, e);
throw new RuntimeException("File download failed", e);
}
}
@Override
public boolean delete(String fileKey) {
try {
S3Client client = getS3Client();
DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucketName)
.key(fileKey)
.build();
client.deleteObject(deleteObjectRequest);
log.info("File deleted from S3 successfully: {}", fileKey);
return true;
} catch (Exception e) {
log.error("Failed to delete file from S3: {}", fileKey, e);
return false;
}
}
@Override
public String getFileUrl(String fileKey) {
return fileKey;
}
@Override
public String generatePresignedUrl(String fileKey, int expireSeconds) {
return generatePresignedUrl(fileKey, expireSeconds, null);
}
@Override
public String generatePresignedUrl(String fileKey, int expireSeconds, String fileName) {
try {
S3Presigner presigner = getS3Presigner();
// Remove leading slash from fileKey if present
String objectKey = fileKey.startsWith("/") ? fileKey.substring(1) : fileKey;
GetObjectRequest.Builder requestBuilder = GetObjectRequest.builder()
.bucket(bucketName)
.key(objectKey);
// Add Content-Disposition header if fileName is provided
if (fileName != null && !fileName.isEmpty()) {
requestBuilder.responseContentDisposition("attachment; filename=\"" + URLEncoder.encode(fileName, StandardCharsets.UTF_8) + "\"");
}
GetObjectRequest getObjectRequest = requestBuilder.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(expireSeconds))
.getObjectRequest(getObjectRequest)
.build();
PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
String url = presignedRequest.url().toString();
log.info("S3 presigned URL generated successfully: {}", fileKey);
return url;
} catch (Exception e) {
log.error("Failed to generate S3 presigned URL: {}", fileKey, e);
return null;
}
}
@Override
public String getStorageType() {
return "s3";
}
}

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xspaceagi.file.infra.dao.mapper.FileRecordMapper">
<resultMap id="BaseResultMap" type="com.xspaceagi.file.infra.dao.entity.FileRecord">
<id property="id" column="id" jdbcType="BIGINT"/>
<result property="tenantId" column="_tenant_id" jdbcType="BIGINT"/>
<result property="userId" column="user_id" jdbcType="BIGINT"/>
<result property="targetType" column="target_type" jdbcType="VARCHAR"/>
<result property="targetId" column="target_id" jdbcType="BIGINT"/>
<result property="fileName" column="file_name" jdbcType="VARCHAR"/>
<result property="fileSize" column="file_size" jdbcType="BIGINT"/>
<result property="fileType" column="file_type" jdbcType="VARCHAR"/>
<result property="fileExtension" column="file_extension" jdbcType="VARCHAR"/>
<result property="metadata" column="metadata" jdbcType="VARCHAR"/>
<result property="fileKey" column="file_key" jdbcType="VARCHAR"/>
<result property="storageType" column="storage_type" jdbcType="VARCHAR"/>
<result property="fileUrl" column="file_url" jdbcType="VARCHAR"/>
<result property="authRequired" column="auth_required" jdbcType="BOOLEAN"/>
<result property="status" column="status" jdbcType="VARCHAR"/>
<result property="created" column="created" jdbcType="TIMESTAMP"/>
<result property="modified" column="modified" jdbcType="TIMESTAMP"/>
</resultMap>
<sql id="Base_Column_List">
id, _tenant_id, user_id, target_type, target_id, file_name, file_size, file_type,
file_extension, metadata, file_key, storage_type, file_url, auth_required, status, created, modified
</sql>
<select id="findByTenantIdAndUserId" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM file_record
WHERE _tenant_id = #{tenantId}
AND user_id = #{userId}
AND status = 'active'
ORDER BY created DESC
</select>
<select id="findByTarget" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM file_record
WHERE _tenant_id = #{tenantId}
AND target_type = #{targetType}
AND target_id = #{targetId}
AND status = 'active'
ORDER BY created DESC
</select>
<select id="findByFileKey" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM file_record
WHERE file_key = #{fileKey}
LIMIT 1
</select>
<select id="findByStorageType" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM file_record
WHERE _tenant_id = #{tenantId}
AND storage_type = #{storageType}
AND status = 'active'
ORDER BY created DESC
</select>
<update id="updateStatusByIds">
UPDATE file_record
SET status = #{status},
modified = NOW()
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</update>
</mapper>

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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