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