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