diff --git a/backend-java/pom.xml b/backend-java/pom.xml index b77e5f9..2be4c9a 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -26,7 +26,7 @@ 1.5.5.Final 5.8.32 2.0.53 - 5.6.227 + 3.17.1 @@ -130,11 +130,11 @@ ${fastjson2.version} - + - com.qcloud - cos_api - ${cos.version} + com.aliyun.oss + aliyun-sdk-oss + ${aliyun-oss.version} diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java index c99b7ed..c0c1d5a 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java @@ -1,5 +1,7 @@ package com.competition.modules.leai.service; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; import com.competition.common.exception.BusinessException; import com.competition.modules.leai.config.LeaiConfig; import com.competition.modules.leai.util.LeaiUtil; @@ -7,13 +9,10 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; @@ -24,14 +23,15 @@ import java.util.*; /** * 乐读派 API 客户端 - * 使用 RestTemplate + Jackson 对接乐读派后端 + * 使用 Hutool HttpRequest + Jackson 对接乐读派后端 */ @Slf4j @Component @RequiredArgsConstructor public class LeaiApiClient { - private final RestTemplate restTemplate; + private static final int TIMEOUT_MS = 10_000; + private final LeaiConfig leaiConfig; private final ObjectMapper objectMapper; @@ -57,13 +57,17 @@ public class LeaiApiClient { body.put("phone", phone); try { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); + String jsonBody = objectMapper.writeValueAsString(body); - HttpEntity entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers); - ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + HttpResponse httpResponse = HttpRequest.post(url) + .body(jsonBody) + .contentType("application/json") + .timeout(TIMEOUT_MS) + .execute(); - Map result = objectMapper.readValue(response.getBody(), + String responseBody = httpResponse.body(); + + Map result = objectMapper.readValue(responseBody, new TypeReference>() {}); int code = LeaiUtil.toInt(result.get("code"), 0); @@ -99,24 +103,23 @@ public class LeaiApiClient { */ public Map fetchWorkDetail(String workId) { // orgId 对应本项目租户 code(tenant_code) - Map queryParams = new TreeMap<>(); + Map queryParams = new TreeMap<>(); queryParams.put("orgId", leaiConfig.getOrgId()); Map hmacHeaders = buildHmacHeaders(queryParams); try { - String url = leaiConfig.getApiUrl() + "/api/v1/query/work/" - + URLEncoder.encode(workId, StandardCharsets.UTF_8) - + "?orgId=" + URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8); // 租户 code + String url = leaiConfig.getApiUrl() + "/api/v1/query/work/" + workId; - HttpHeaders headers = new HttpHeaders(); - hmacHeaders.forEach(headers::set); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + HttpResponse httpResponse = HttpRequest.get(url) + .form(queryParams) + .addHeaders(hmacHeaders) + .timeout(TIMEOUT_MS) + .execute(); - HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + String responseBody = httpResponse.body(); - Map result = objectMapper.readValue(response.getBody(), + Map result = objectMapper.readValue(responseBody, new TypeReference>() {}); int code = LeaiUtil.toInt(result.get("code"), 0); @@ -141,7 +144,7 @@ public class LeaiApiClient { */ public List> queryWorks(String updatedAfter) { // orgId 对应本项目租户 code(tenant_code) - Map queryParams = new TreeMap<>(); + Map queryParams = new TreeMap<>(); queryParams.put("orgId", leaiConfig.getOrgId()); queryParams.put("updatedAfter", updatedAfter); queryParams.put("page", "1"); @@ -150,24 +153,22 @@ public class LeaiApiClient { Map hmacHeaders = buildHmacHeaders(queryParams); try { - StringBuilder queryString = new StringBuilder(); - for (Map.Entry e : queryParams.entrySet()) { - if (queryString.length() > 0) queryString.append("&"); - queryString.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)) - .append("=") - .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); - } + String url = leaiConfig.getApiUrl() + "/api/v1/query/works"; - String url = leaiConfig.getApiUrl() + "/api/v1/query/works?" + queryString; + log.info("[乐读派] B3查询请求: url={}, params={}, headers={}", url, queryParams, hmacHeaders); - HttpHeaders headers = new HttpHeaders(); - hmacHeaders.forEach(headers::set); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + HttpResponse httpResponse = HttpRequest.get(url) + .form(queryParams) + .addHeaders(hmacHeaders) + .timeout(TIMEOUT_MS) + .execute(); - HttpEntity entity = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + String responseBody = httpResponse.body(); + int status = httpResponse.getStatus(); - Map result = objectMapper.readValue(response.getBody(), + log.info("[乐读派] B3查询响应: status={}, body={}", status, responseBody); + + Map result = objectMapper.readValue(responseBody, new TypeReference>() {}); int code = LeaiUtil.toInt(result.get("code"), 0); @@ -198,11 +199,12 @@ public class LeaiApiClient { /** * 生成 HMAC 签名请求头 */ - public Map buildHmacHeaders(Map queryParams) { + public Map buildHmacHeaders(Map queryParams) { String ts = String.valueOf(System.currentTimeMillis()); String nonce = Long.toHexString(System.currentTimeMillis()) + Long.toHexString(System.nanoTime()); - TreeMap allParams = new TreeMap<>(queryParams); + TreeMap allParams = new TreeMap<>(); + queryParams.forEach((k, v) -> allParams.put(k, String.valueOf(v))); allParams.put("timestamp", ts); allParams.put("nonce", nonce); @@ -212,8 +214,15 @@ public class LeaiApiClient { signStr.append(entry.getKey()).append("=").append(entry.getValue()); } + log.debug("[乐读派签名] orgId={}, appSecret={}***", leaiConfig.getOrgId(), + leaiConfig.getAppSecret() != null ? leaiConfig.getAppSecret().substring(0, Math.min(6, leaiConfig.getAppSecret().length())) : "null"); + log.debug("[乐读派签名] 待签名字符串: {}", signStr); + String sig = hmacSha256(signStr.toString(), leaiConfig.getAppSecret()); + log.debug("[乐读派签名] 签名结果: {}", sig); + log.debug("[乐读派签名] 请求头: X-App-Key={}, X-Timestamp={}, X-Nonce={}", leaiConfig.getOrgId(), ts, nonce); + Map headers = new LinkedHashMap<>(); headers.put("X-App-Key", leaiConfig.getOrgId()); // X-App-Key 即租户 code(tenant_code) headers.put("X-Timestamp", ts); diff --git a/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java b/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java index fb4de58..a3ee7a0 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java +++ b/backend-java/src/main/java/com/competition/modules/leai/task/LeaiReconcileTask.java @@ -26,7 +26,7 @@ public class LeaiReconcileTask { private final ILeaiSyncService leaiSyncService; /** - * 每30分钟执行一次,初始延迟60秒 + * 每1分钟执行一次(测试阶段,正式环境改为30分钟),初始延迟10秒 */ @Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000) public void reconcile() { diff --git a/backend-java/src/main/java/com/competition/modules/oss/config/OssConfig.java b/backend-java/src/main/java/com/competition/modules/oss/config/OssConfig.java index 975381d..fdd0191 100644 --- a/backend-java/src/main/java/com/competition/modules/oss/config/OssConfig.java +++ b/backend-java/src/main/java/com/competition/modules/oss/config/OssConfig.java @@ -4,18 +4,51 @@ import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; +/** + * 阿里云 OSS 配置 + */ @Data @Configuration -@ConfigurationProperties(prefix = "oss") +@ConfigurationProperties(prefix = "aliyun.oss") public class OssConfig { - private String secretId; + /** OSS Endpoint(如:oss-cn-guangzhou.aliyuncs.com) */ + private String endpoint; - private String secretKey; + /** AccessKey ID */ + private String accessKeyId; - private String bucket; + /** AccessKey Secret */ + private String accessKeySecret; - private String region = "ap-guangzhou"; + /** Bucket 名称 */ + private String bucketName; - private String urlPrefix; + /** 最大文件大小(字节),默认 10MB */ + private long maxFileSize = 10485760; + + /** 是否自动配置 CORS */ + private Boolean corsEnabled = true; + + /** CORS 允许的来源(逗号分隔) */ + private String corsAllowedOrigins = "http://localhost:3000"; + + /** 允许的文件扩展名 */ + private String[] allowedExtensions = { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".mp4", ".mp3", ".wav", ".avi", + ".zip", ".rar", + ".txt", ".csv" + }; + + /** + * 获取完整的 Endpoint 地址(https://前缀) + */ + public String getFullEndpoint() { + if (endpoint == null) { + return ""; + } + return endpoint.startsWith("https://") ? endpoint : "https://" + endpoint; + } } diff --git a/backend-java/src/main/java/com/competition/modules/oss/config/OssCorsInitRunner.java b/backend-java/src/main/java/com/competition/modules/oss/config/OssCorsInitRunner.java new file mode 100644 index 0000000..66996df --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/oss/config/OssCorsInitRunner.java @@ -0,0 +1,30 @@ +package com.competition.modules.oss.config; + +import com.competition.modules.oss.util.OssUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * OSS Bucket CORS 初始化 + *

+ * 应用启动时自动配置 OSS 跨域规则,解决前端直传跨域问题。 + * 需在配置中开启:aliyun.oss.cors-enabled=true + *

+ */ +@Slf4j +@Component +@Order(100) +@RequiredArgsConstructor +public class OssCorsInitRunner implements ApplicationRunner { + + private final OssUtils ossUtils; + + @Override + public void run(ApplicationArguments args) { + ossUtils.configureBucketCors(); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/oss/controller/UploadController.java b/backend-java/src/main/java/com/competition/modules/oss/controller/UploadController.java index 224f465..8b8347f 100644 --- a/backend-java/src/main/java/com/competition/modules/oss/controller/UploadController.java +++ b/backend-java/src/main/java/com/competition/modules/oss/controller/UploadController.java @@ -2,13 +2,13 @@ package com.competition.modules.oss.controller; import com.competition.common.result.Result; import com.competition.modules.oss.service.OssService; +import com.competition.modules.oss.util.OssUtils; +import com.competition.modules.oss.vo.OssTokenVo; +import com.competition.security.annotation.Public; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.Map; @@ -20,8 +20,9 @@ import java.util.Map; public class UploadController { private final OssService ossService; + private final OssUtils ossUtils; - @Operation(summary = "上传文件") + @Operation(summary = "服务端上传文件(向后兼容)") @PostMapping public Result> upload(@RequestParam("file") MultipartFile file) { String url = ossService.uploadFile(file); @@ -31,4 +32,14 @@ public class UploadController { "size", file.getSize() )); } + + @Public + @Operation(summary = "获取 OSS 直传 Token") + @GetMapping("/oss/token") + public Result getOssToken( + @RequestParam("fileName") String fileName, + @RequestParam(value = "dir", required = false) String dir) { + OssTokenVo token = ossUtils.generatePostObjectToken(fileName, dir); + return Result.success(token); + } } diff --git a/backend-java/src/main/java/com/competition/modules/oss/service/OssService.java b/backend-java/src/main/java/com/competition/modules/oss/service/OssService.java index 72086bd..bf993ac 100644 --- a/backend-java/src/main/java/com/competition/modules/oss/service/OssService.java +++ b/backend-java/src/main/java/com/competition/modules/oss/service/OssService.java @@ -1,100 +1,29 @@ package com.competition.modules.oss.service; -import com.competition.modules.oss.config.OssConfig; -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.model.ObjectMetadata; -import com.qcloud.cos.model.PutObjectRequest; -import com.qcloud.cos.region.Region; +import com.competition.modules.oss.util.OssUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.UUID; - +/** + * OSS 文件上传服务 + *

+ * 委托给 OssUtils 处理实际的上传逻辑。 + * 主要用于向后兼容服务端上传接口。 + *

+ */ @Slf4j @Service @RequiredArgsConstructor public class OssService { - private final OssConfig ossConfig; + private final OssUtils ossUtils; /** - * 上传文件,优先使用腾讯云 COS,未配置时降级到本地存储 + * 上传文件到 OSS(服务端上传,向后兼容) */ public String uploadFile(MultipartFile file) { - String originalFilename = file.getOriginalFilename(); - String ext = ""; - if (originalFilename != null && originalFilename.contains(".")) { - ext = originalFilename.substring(originalFilename.lastIndexOf(".")); - } - - String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); - String uniqueName = UUID.randomUUID().toString().replace("-", "") + ext; - String key = datePath + "/" + uniqueName; - - if (StringUtils.hasText(ossConfig.getSecretId())) { - return uploadToCos(file, key); - } else { - return uploadToLocal(file, key); - } - } - - private String uploadToCos(MultipartFile file, String key) { - COSCredentials cred = new BasicCOSCredentials(ossConfig.getSecretId(), ossConfig.getSecretKey()); - ClientConfig clientConfig = new ClientConfig(new Region(ossConfig.getRegion())); - COSClient cosClient = new COSClient(cred, clientConfig); - - try { - ObjectMetadata metadata = new ObjectMetadata(); - metadata.setContentLength(file.getSize()); - metadata.setContentType(file.getContentType()); - - PutObjectRequest putRequest = new PutObjectRequest( - ossConfig.getBucket(), key, file.getInputStream(), metadata); - cosClient.putObject(putRequest); - - String url; - if (StringUtils.hasText(ossConfig.getUrlPrefix())) { - url = ossConfig.getUrlPrefix().replaceAll("/$", "") + "/" + key; - } else { - url = "https://" + ossConfig.getBucket() + ".cos." + ossConfig.getRegion() + ".myqcloud.com/" + key; - } - - log.info("文件上传至 COS 成功: {}", url); - return url; - } catch (IOException e) { - log.error("文件上传至 COS 失败", e); - throw new RuntimeException("文件上传失败", e); - } finally { - cosClient.shutdown(); - } - } - - private String uploadToLocal(MultipartFile file, String key) { - try { - Path basePath = Paths.get(System.getProperty("user.dir"), "uploads"); - Path filePath = basePath.resolve(key); - Files.createDirectories(filePath.getParent()); - file.transferTo(filePath.toAbsolutePath().toFile()); - - String url = "/uploads/" + key; - log.info("文件上传至本地成功: {}", filePath.toAbsolutePath()); - return url; - } catch (IOException e) { - log.error("文件上传至本地失败", e); - throw new RuntimeException("文件上传失败", e); - } + return ossUtils.uploadFile(file); } } diff --git a/backend-java/src/main/java/com/competition/modules/oss/util/OssUtils.java b/backend-java/src/main/java/com/competition/modules/oss/util/OssUtils.java new file mode 100644 index 0000000..966d7f6 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/oss/util/OssUtils.java @@ -0,0 +1,243 @@ +package com.competition.modules.oss.util; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.ObjectMetadata; +import com.aliyun.oss.model.PutObjectRequest; +import com.aliyun.oss.model.SetBucketCORSRequest; +import com.competition.modules.oss.config.OssConfig; +import com.competition.modules.oss.vo.OssTokenVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +/** + * 阿里云 OSS 工具类 + *

+ * 包含:前端直传签名生成、服务端上传、CORS 配置 + *

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OssUtils { + + private final OssConfig ossConfig; + + /** + * 获取 OSS 客户端 + */ + private OSS getOssClient() { + return new OSSClientBuilder().build( + ossConfig.getEndpoint(), + ossConfig.getAccessKeyId(), + ossConfig.getAccessKeySecret() + ); + } + + // ==================== 前端直传 ==================== + + /** + * 生成阿里云 OSS PostObject 直传 Token + * + * @param fileName 原始文件名 + * @param dir 目录前缀(可选) + * @return OSS 直传 Token VO + */ + public OssTokenVo generatePostObjectToken(String fileName, String dir) { + // 校验文件名 + if (fileName == null || fileName.isEmpty()) { + throw new IllegalArgumentException("文件名不能为空"); + } + + // 校验文件扩展名 + if (!isAllowedExtension(fileName)) { + throw new IllegalArgumentException("不支持的文件类型:" + fileName); + } + + // 获取文件扩展名 + String extension = getFileExtension(fileName); + + // 生成唯一文件名 + String uniqueFilename = UUID.randomUUID().toString().replace("-", "") + extension; + + // 生成日期路径 + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + "/"; + + // 组合存储路径 + String objectKey; + if (dir != null && !dir.isEmpty()) { + if (!dir.endsWith("/")) { + dir = dir + "/"; + } + objectKey = dir + datePath + uniqueFilename; + } else { + objectKey = datePath + uniqueFilename; + } + + // 设置过期时间(30 秒) + int expire = 30; + long expiration = System.currentTimeMillis() + expire * 1000L; + + // 构建 PostPolicy + String policy = buildPostPolicy(objectKey, expiration); + + // 计算签名 + String signature = computeSignature(policy, ossConfig.getAccessKeySecret()); + + // 构建上传地址 + String host = "https://" + ossConfig.getBucketName() + "." + ossConfig.getEndpoint(); + + return OssTokenVo.builder() + .accessid(ossConfig.getAccessKeyId()) + .policy(policy) + .signature(signature) + .dir(dir != null ? dir : "") + .host(host) + .key(objectKey) + .expire(expire) + .build(); + } + + // ==================== 服务端上传(向后兼容) ==================== + + /** + * 服务端上传文件到 OSS + * + * @param file 上传的文件 + * @return 文件 URL + */ + public String uploadFile(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + String ext = getFileExtension(originalFilename); + + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String uniqueName = UUID.randomUUID().toString().replace("-", "") + ext; + String key = "server-upload/" + datePath + "/" + uniqueName; + + OSS ossClient = getOssClient(); + try { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + PutObjectRequest putRequest = new PutObjectRequest( + ossConfig.getBucketName(), key, file.getInputStream(), metadata); + ossClient.putObject(putRequest); + + String url = "https://" + ossConfig.getBucketName() + "." + ossConfig.getEndpoint() + "/" + key; + log.info("文件上传至 OSS 成功: {}", url); + return url; + } catch (IOException e) { + log.error("文件上传至 OSS 失败", e); + throw new RuntimeException("文件上传失败", e); + } finally { + ossClient.shutdown(); + } + } + + // ==================== CORS 配置 ==================== + + /** + * 配置 OSS Bucket CORS 规则 + */ + public void configureBucketCors() { + if (!Boolean.TRUE.equals(ossConfig.getCorsEnabled())) { + log.info("OSS CORS 自动配置已禁用"); + return; + } + + OSS ossClient = getOssClient(); + try { + SetBucketCORSRequest request = new SetBucketCORSRequest(ossConfig.getBucketName()); + SetBucketCORSRequest.CORSRule rule = new SetBucketCORSRequest.CORSRule(); + + String origins = ossConfig.getCorsAllowedOrigins(); + if (origins == null || origins.isBlank()) { + origins = "http://localhost:3000,http://localhost:5173"; + } + rule.setAllowedOrigins(Arrays.asList(origins.trim().split("\\s*,\\s*"))); + rule.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "HEAD")); + rule.setAllowedHeaders(Arrays.asList("*")); + rule.setExposeHeaders(Arrays.asList("ETag", "x-oss-request-id")); + rule.setMaxAgeSeconds(600); + + request.setCorsRules(List.of(rule)); + ossClient.setBucketCORS(request); + log.info("OSS Bucket CORS 配置成功,允许来源: {}", origins); + } catch (Exception e) { + log.warn("OSS Bucket CORS 配置失败(可在阿里云控制台手动配置): {}", e.getMessage()); + } finally { + ossClient.shutdown(); + } + } + + // ==================== 私有辅助方法 ==================== + + /** + * 构建 PostPolicy + */ + private String buildPostPolicy(String objectKey, long expiration) { + String expireTime = java.time.Instant.ofEpochMilli(expiration).toString(); + String policyJson = String.format( + "{\"expiration\":\"%s\",\"conditions\":[[\"eq\",\"$key\",\"%s\"]]}", + expireTime, objectKey + ); + return Base64.getEncoder().encodeToString(policyJson.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 计算签名(HMAC-SHA1) + */ + private String computeSignature(String policy, String accessKeySecret) { + try { + Mac mac = Mac.getInstance("HmacSHA1"); + SecretKeySpec keySpec = new SecretKeySpec( + accessKeySecret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"); + mac.init(keySpec); + byte[] signData = mac.doFinal(policy.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(signData); + } catch (Exception e) { + log.error("计算 OSS 签名失败:{}", e.getMessage(), e); + throw new RuntimeException("计算 OSS 签名失败:" + e.getMessage(), e); + } + } + + /** + * 检查文件扩展名是否允许 + */ + private boolean isAllowedExtension(String filename) { + if (filename == null || filename.isEmpty()) { + return false; + } + String extension = getFileExtension(filename).toLowerCase(); + return Arrays.stream(ossConfig.getAllowedExtensions()) + .anyMatch(allowed -> allowed.toLowerCase().equals(extension)); + } + + /** + * 获取文件扩展名(包含点) + */ + private String getFileExtension(String filename) { + if (filename == null || filename.isEmpty()) { + return ""; + } + int lastDot = filename.lastIndexOf("."); + if (lastDot == -1) { + return ""; + } + return filename.substring(lastDot); + } +} diff --git a/backend-java/src/main/java/com/competition/modules/oss/vo/OssTokenVo.java b/backend-java/src/main/java/com/competition/modules/oss/vo/OssTokenVo.java new file mode 100644 index 0000000..c2cc6f2 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/oss/vo/OssTokenVo.java @@ -0,0 +1,37 @@ +package com.competition.modules.oss.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 阿里云 OSS 直传 Token 响应 VO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OssTokenVo { + + /** OSS 访问 ID(AccessKeyId) */ + private String accessid; + + /** 合法性策略(Base64 编码的 Policy) */ + private String policy; + + /** 签名信息 */ + private String signature; + + /** 上传目录前缀 */ + private String dir; + + /** OSS 上传地址(https://bucketname.endpoint) */ + private String host; + + /** 完整文件路径 */ + private String key; + + /** 过期时间(秒) */ + private Integer expire; +} diff --git a/backend-java/src/main/resources/application-dev.yml b/backend-java/src/main/resources/application-dev.yml index a09c484..2e2149d 100644 --- a/backend-java/src/main/resources/application-dev.yml +++ b/backend-java/src/main/resources/application-dev.yml @@ -33,12 +33,17 @@ mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl -oss: - secret-id: ${COS_SECRET_ID:} - secret-key: ${COS_SECRET_KEY:}, - bucket: ${COS_BUCKET:} - region: ${COS_REGION:ap-guangzhou} - url-prefix: ${COS_URL_PREFIX:} +# 阿里云 OSS 配置(开发环境) +aliyun: + oss: + endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com} + access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} + bucket-name: ${OSS_BUCKET_NAME:lesingle-creation} + max-file-size: ${OSS_MAX_FILE_SIZE:10485760} + # 前端直传跨域:启动时自动配置 OSS CORS + cors-enabled: ${OSS_CORS_ENABLED:true} + cors-allowed-origins: ${OSS_CORS_ORIGINS:*} logging: level: diff --git a/backend-java/src/main/resources/application-prod.yml b/backend-java/src/main/resources/application-prod.yml index 237919c..aeafd26 100644 --- a/backend-java/src/main/resources/application-prod.yml +++ b/backend-java/src/main/resources/application-prod.yml @@ -38,3 +38,15 @@ knife4j: logging: level: com.competition: info + +# 阿里云 OSS 配置(开发环境) +aliyun: + oss: + endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com} + access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} + bucket-name: ${OSS_BUCKET_NAME:lesingle-creation} + max-file-size: ${OSS_MAX_FILE_SIZE:10485760} + # 前端直传跨域:启动时自动配置 OSS CORS + cors-enabled: ${OSS_CORS_ENABLED:true} + cors-allowed-origins: ${OSS_CORS_ORIGINS:*} \ No newline at end of file diff --git a/backend-java/src/main/resources/application-test.yml b/backend-java/src/main/resources/application-test.yml index d91937c..2e2149d 100644 --- a/backend-java/src/main/resources/application-test.yml +++ b/backend-java/src/main/resources/application-test.yml @@ -33,12 +33,17 @@ mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl -oss: - secret-id: ${COS_SECRET_ID:} - secret-key: ${COS_SECRET_KEY:} - bucket: ${COS_BUCKET:} - region: ${COS_REGION:ap-guangzhou} - url-prefix: ${COS_URL_PREFIX:} +# 阿里云 OSS 配置(开发环境) +aliyun: + oss: + endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com} + access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} + bucket-name: ${OSS_BUCKET_NAME:lesingle-creation} + max-file-size: ${OSS_MAX_FILE_SIZE:10485760} + # 前端直传跨域:启动时自动配置 OSS CORS + cors-enabled: ${OSS_CORS_ENABLED:true} + cors-allowed-origins: ${OSS_CORS_ORIGINS:*} logging: level: diff --git a/frontend/e2e/upload/fixtures/test-upload.png b/frontend/e2e/upload/fixtures/test-upload.png new file mode 100644 index 0000000..0f2de37 Binary files /dev/null and b/frontend/e2e/upload/fixtures/test-upload.png differ diff --git a/frontend/e2e/upload/oss-upload.spec.ts b/frontend/e2e/upload/oss-upload.spec.ts new file mode 100644 index 0000000..23d83b5 --- /dev/null +++ b/frontend/e2e/upload/oss-upload.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from '@playwright/test' +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// 测试配置 +const TENANT_CODE = 'super' +const USERNAME = 'admin' +const PASSWORD = 'admin123' + +// 确保测试图片存在 +const FIXTURES_DIR = path.join(__dirname, 'fixtures') +const TEST_IMAGE_PATH = path.join(FIXTURES_DIR, 'test-upload.png') +if (!fs.existsSync(FIXTURES_DIR)) { + fs.mkdirSync(FIXTURES_DIR, { recursive: true }) +} +if (!fs.existsSync(TEST_IMAGE_PATH)) { + const pngData = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + 'base64' + ) + fs.writeFileSync(TEST_IMAGE_PATH, pngData) +} + +test.describe('OSS 直传上传', () => { + + // 单独给这个测试更长的超时 + test.setTimeout(60000) + + test('登录 -> 赛事创建页 -> 上传封面图片到 OSS', async ({ page }) => { + // 监听网络请求,捕获 OSS 相关请求 + const ossTokenRequests: string[] = [] + const ossUploadRequests: string[] = [] + + page.on('request', (req) => { + const url = req.url() + if (url.includes('/upload/oss/token')) { + ossTokenRequests.push(url) + } + if (url.includes('aliyuncs.com')) { + ossUploadRequests.push(url) + } + }) + + // ========== 1. 登录 ========== + await page.goto(`/${TENANT_CODE}/login`) + await page.waitForLoadState('domcontentloaded') + + // 等待登录表单可见 + await page.locator('input[placeholder="请输入用户名"]').waitFor({ state: 'visible', timeout: 10000 }) + + // 填写 Ant Design 表单 + await page.locator('input[placeholder="请输入用户名"]').click() + await page.locator('input[placeholder="请输入用户名"]').fill(USERNAME) + await page.locator('input[placeholder="请输入密码"]').click() + await page.locator('input[placeholder="请输入密码"]').fill(PASSWORD) + + // 点击登录按钮 + const loginBtn = page.locator('button[type="submit"]').first() + await loginBtn.click() + + // 等待登录成功(URL 不再包含 /login) + await page.waitForFunction(() => !window.location.pathname.includes('/login'), { timeout: 15000 }) + console.log('[1] 登录成功, 当前页面:', page.url()) + + // ========== 2. 进入赛事创建页 ========== + await page.goto(`/${TENANT_CODE}/contests/create`) + await page.waitForLoadState('domcontentloaded') + + // 等待表单页面加载 + await page.locator('input[placeholder*="活动名称"], input[placeholder*="名称"]').first().waitFor({ timeout: 10000 }) + console.log('[2] 赛事创建页加载成功') + + // ========== 3. 上传封面图片 ========== + // 直接用全局的 file input(Ant Design Upload 的隐藏 input) + const fileInputs = page.locator('input[type="file"]') + const fileCount = await fileInputs.count() + console.log('[3] 发现 file input 数量:', fileCount) + + // 第一个 file input 对应封面上传 + await fileInputs.first().setInputFiles(TEST_IMAGE_PATH) + + console.log('[3] 已选择封面文件,等待 OSS 上传...') + + // 等待网络请求完成 + await page.waitForTimeout(5000) + + // ========== 4. 验证 ========== + console.log('[4] OSS Token 请求数:', ossTokenRequests.length) + console.log('[4] OSS 上传请求数:', ossUploadRequests.length) + + // 验证:发出了 OSS Token 请求 + if (ossTokenRequests.length > 0) { + console.log('[4] Token 请求 URL:', ossTokenRequests[0]) + } + + // 验证:发出了 OSS 上传请求 + if (ossUploadRequests.length > 0) { + console.log('[4] 上传目标 URL:', ossUploadRequests[0]) + expect(ossUploadRequests[0]).toContain('aliyuncs.com') + } + + // 验证:检查页面上是否有上传成功的 UI 指示 + const successItems = page.locator('.ant-upload-list-item-done') + const errorItems = page.locator('.ant-upload-list-item-error') + const successCount = await successItems.count() + const errorCount = await errorItems.count() + + console.log('[4] 上传成功项:', successCount, '上传失败项:', errorCount) + + // 检查是否有错误提示消息 + const errorMsg = await page.locator('.ant-message-error').textContent().catch(() => '') + if (errorMsg) { + console.log('[4] 错误消息:', errorMsg) + } + + // 核心断言 + expect(ossTokenRequests.length).toBeGreaterThanOrEqual(1) + expect(ossUploadRequests.length).toBeGreaterThanOrEqual(1) + expect(errorCount).toBe(0) + + console.log('\n===== OSS 直传上传测试通过 =====') + console.log('OSS Token 请求:', ossTokenRequests.length) + console.log('OSS 上传请求:', ossUploadRequests.length) + console.log('上传目标:', ossUploadRequests[0] || 'N/A') + }) +}) diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html new file mode 100644 index 0000000..6c72395 --- /dev/null +++ b/frontend/playwright-report/index.html @@ -0,0 +1,90 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts index 5e1aa8a..7938129 100644 --- a/frontend/src/api/upload.ts +++ b/frontend/src/api/upload.ts @@ -1,5 +1,10 @@ +import axios from "axios"; import request from "@/utils/request"; +import { buildOssDirPath } from "@/utils/oss-env"; +/** + * 上传响应接口(保持向后兼容) + */ export interface UploadResponse { url: string; filename: string; @@ -7,27 +12,121 @@ export interface UploadResponse { size: number; } -export const uploadApi = { - // 上传文件 - upload: async (formData: FormData): Promise => { - const response = await request.post( - "/upload", - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, +/** + * OSS 直传 Token 响应 + */ +interface OssToken { + accessid: string; + policy: string; + signature: string; + dir: string; + host: string; + key: string; + expire: number; +} + +/** + * 上传选项 + */ +interface UploadOptions { + /** 上传进度回调(0-100) */ + onProgress?: (percent: number) => void; + /** 取消信号 */ + signal?: AbortSignal; +} + +/** + * 获取 OSS 直传 Token + */ +async function getOssToken( + fileName: string, + dir?: string, +): Promise { + const fullDir = buildOssDirPath(dir); + const response = await request.get("/upload/oss/token", { + params: { fileName, dir: fullDir }, + }); + return response; +} + +/** + * 直接上传文件到阿里云 OSS + */ +async function uploadToOss( + file: File, + token: OssToken, + options?: UploadOptions, +): Promise { + const formData = new FormData(); + + // 按照阿里云 OSS PostObject 要求构造表单 + // 注意:file 必须为最后一个表单域 + formData.append("success_action_status", "200"); + formData.append("OSSAccessKeyId", token.accessid); + formData.append("policy", token.policy); + formData.append("signature", token.signature); + formData.append("key", token.key); + formData.append("file", file); + + await axios.post(token.host, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + timeout: 1000 * 60 * 5, // 默认 5 分钟 + signal: options?.signal, + onUploadProgress: (progressEvent) => { + if (options?.onProgress) { + const percent = + progressEvent.progress != null + ? progressEvent.progress * 100 + : progressEvent.total + ? (progressEvent.loaded * 100) / progressEvent.total + : 0; + options.onProgress(Math.round(percent)); } - ); - return response; + }, + }); +} + +/** + * 上传 API(保持接口兼容) + */ +export const uploadApi = { + /** + * 上传文件(通过 FormData) + * @param formData 包含 file 字段的表单数据 + * @param dir 业务目录(如:homework/attachment) + */ + upload: async (formData: FormData, dir?: string): Promise => { + const file = formData.get("file") as File; + return uploadFile(file, dir); }, }; /** - * 上传单个文件 + * 上传单个文件(OSS 直传) + * + * @param file 要上传的文件 + * @param dir 业务目录(如:avatar, course/cover),会自动添加环境前缀 + * @param options 上传选项(进度回调、取消信号) */ -export async function uploadFile(file: File): Promise { - const formData = new FormData(); - formData.append("file", file); - return uploadApi.upload(formData); +export async function uploadFile( + file: File, + dir?: string, + options?: UploadOptions, +): Promise { + // 1. 获取 OSS 直传 Token + const token = await getOssToken(file.name, dir); + + // 2. 直传到 OSS + await uploadToOss(file, token, options); + + // 3. 返回结果(保持 UploadResponse 接口兼容) + const url = `${token.host}/${token.key}`; + return { + url, + filename: file.name, + originalname: file.name, + size: file.size, + }; } diff --git a/frontend/src/components/RichTextEditor.vue b/frontend/src/components/RichTextEditor.vue index 6a91033..bb0b183 100644 --- a/frontend/src/components/RichTextEditor.vue +++ b/frontend/src/components/RichTextEditor.vue @@ -69,7 +69,7 @@ const editorConfig: Partial = { uploadImage: { async customUpload(file: File, insertFn: (url: string) => void) { try { - const result: any = await uploadFile(file) + const result: any = await uploadFile(file, "editor/image") const url = result.data?.url || result.url if (url) { insertFn(url) diff --git a/frontend/src/utils/oss-env.ts b/frontend/src/utils/oss-env.ts new file mode 100644 index 0000000..15e481c --- /dev/null +++ b/frontend/src/utils/oss-env.ts @@ -0,0 +1,46 @@ +/** + * OSS 环境工具函数 + * + * 根据当前运行环境自动添加 OSS 路径前缀(dev/test/prod), + * 不同环境的文件存储在不同目录下,互不干扰。 + */ + +/** 环境前缀映射 */ +const OSS_ENV_PREFIX_MAP: Record = { + development: "dev", + test: "test", + production: "prod", +}; + +/** + * 获取 OSS 环境前缀 + * @returns 环境前缀(dev/test/prod) + */ +export function getOssEnvPrefix(): string { + const env = import.meta.env.MODE || "development"; + return OSS_ENV_PREFIX_MAP[env] || "dev"; +} + +/** + * 构建完整的 OSS 目录路径 + * + * @param bizDir 业务目录(如:avatar, course/cover) + * @returns 完整目录路径(如:dev/avatar, test/course/cover) + * + * @example + * buildOssDirPath("avatar") // 开发环境 → "dev/avatar" + * buildOssDirPath("course/cover") // 测试环境 → "test/course/cover" + * buildOssDirPath() // 生产环境 → "prod" + */ +export function buildOssDirPath(bizDir?: string): string { + const envPrefix = getOssEnvPrefix(); + + if (!bizDir) { + return envPrefix; + } + + // 移除 bizDir 开头可能存在的环境前缀,避免重复 + const cleanBizDir = bizDir.replace(/^(dev|test|prod)\//, ""); + + return `${envPrefix}/${cleanBizDir}`; +} diff --git a/frontend/src/views/contests/Create.vue b/frontend/src/views/contests/Create.vue index 52f9296..e31d72d 100644 --- a/frontend/src/views/contests/Create.vue +++ b/frontend/src/views/contests/Create.vue @@ -277,7 +277,7 @@ const beforeFileUpload = (file: File) => { const handleCoverUpload = async (options: any) => { const { file, onSuccess, onError } = options try { - const result: any = await uploadFile(file) + const result: any = await uploadFile(file, "contest/cover") const url = result.data?.url || result.url if (url) { form.coverUrl = url; onSuccess(); message.success("封面上传成功") } else throw new Error("无法获取图片地址") @@ -287,7 +287,7 @@ const handleCoverUpload = async (options: any) => { const handlePosterUpload = async (options: any) => { const { file, onSuccess, onError } = options try { - const result: any = await uploadFile(file) + const result: any = await uploadFile(file, "contest/poster") const url = result.data?.url || result.url if (url) { form.posterUrl = url; onSuccess(); message.success("海报上传成功") } else throw new Error("无法获取图片地址") @@ -297,7 +297,7 @@ const handlePosterUpload = async (options: any) => { const handleAttachmentUpload = async (options: any) => { const { file, onSuccess, onError } = options try { - const result: any = await uploadFile(file) + const result: any = await uploadFile(file, "contest/attachment") const url = result.data?.url || result.url if (url) { await nextTick() diff --git a/frontend/src/views/contests/components/SubmitWorkDrawer.vue b/frontend/src/views/contests/components/SubmitWorkDrawer.vue index 940f011..6e39ff1 100644 --- a/frontend/src/views/contests/components/SubmitWorkDrawer.vue +++ b/frontend/src/views/contests/components/SubmitWorkDrawer.vue @@ -206,7 +206,7 @@ import type { FormInstance, UploadFile } from "ant-design-vue" import { worksApi, registrationsApi, type SubmitWorkForm } from "@/api/contests" import { getAI3DTasks, type AI3DTask } from "@/api/ai-3d" import { useAuthStore } from "@/stores/auth" -import request from "@/utils/request" +import { uploadFile } from "@/api/upload" import dayjs from "dayjs" interface Props { @@ -591,8 +591,8 @@ const handleSubmit = async () => { // 上传3D文件 try { - const uploadedUrl = await uploadFile(form.localWorkFile) - modelFiles = [uploadedUrl] + const result = await uploadFile(form.localWorkFile, "contest/work") + modelFiles = [result.url] } catch (error: any) { message.error("3D文件上传失败:" + (error?.message || "未知错误")) submitLoading.value = false @@ -601,7 +601,8 @@ const handleSubmit = async () => { // 上传预览图 try { - previewUrl = await uploadFile(form.localPreviewFile) + const result = await uploadFile(form.localPreviewFile, "contest/preview") + previewUrl = result.url previewUrlsList = [previewUrl] } catch (error: any) { message.error("预览图上传失败:" + (error?.message || "未知错误")) @@ -619,10 +620,10 @@ const handleSubmit = async () => { }> = [] for (const file of form.attachmentFiles) { try { - const url = await uploadFile(file) + const result = await uploadFile(file, "contest/attachment") attachments.push({ fileName: file.name, - fileUrl: url, + fileUrl: result.url, fileType: file.type || undefined, size: String(file.size), }) @@ -657,29 +658,6 @@ const handleSubmit = async () => { } } -// 文件上传函数 -const uploadFile = async (file: File): Promise => { - const formData = new FormData() - formData.append("file", file) - - try { - const response = await request.post("/upload", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }) - - if (response && typeof response === "object" && "url" in response) { - return String(response.url) - } - - throw new Error("文件上传返回格式不正确") - } catch (error: any) { - console.error("文件上传失败:", error) - throw new Error(error?.response?.data?.message || "文件上传失败") - } -} - // 取消 const handleCancel = () => { visible.value = false diff --git a/frontend/src/views/homework/Index.vue b/frontend/src/views/homework/Index.vue index f2d426c..eab8c60 100644 --- a/frontend/src/views/homework/Index.vue +++ b/frontend/src/views/homework/Index.vue @@ -438,7 +438,7 @@ const customUpload = async ({ file, onSuccess, onError }: any) => { try { const formData = new FormData() formData.append("file", file) - const result = await uploadApi.upload(formData) + const result = await uploadApi.upload(formData, "homework/attachment") file.url = result.url onSuccess(result) } catch (error) { diff --git a/frontend/src/views/homework/StudentDetail.vue b/frontend/src/views/homework/StudentDetail.vue index bc32002..4704323 100644 --- a/frontend/src/views/homework/StudentDetail.vue +++ b/frontend/src/views/homework/StudentDetail.vue @@ -359,7 +359,7 @@ const customUpload = async ({ file, onSuccess, onError }: any) => { try { const formData = new FormData() formData.append("file", file) - const result = await uploadApi.upload(formData) + const result = await uploadApi.upload(formData, "homework/attachment") file.url = result.url onSuccess(result) } catch (error) { diff --git a/frontend/src/views/homework/StudentList.vue b/frontend/src/views/homework/StudentList.vue index b2f6941..d4f6248 100644 --- a/frontend/src/views/homework/StudentList.vue +++ b/frontend/src/views/homework/StudentList.vue @@ -387,7 +387,7 @@ const customUpload = async ({ file, onSuccess, onError }: any) => { try { const formData = new FormData() formData.append("file", file) - const result = await uploadApi.upload(formData) + const result = await uploadApi.upload(formData, "homework/attachment") file.url = result.url onSuccess(result) } catch (error) { diff --git a/frontend/test-results/.last-run.json b/frontend/test-results/.last-run.json new file mode 100644 index 0000000..da8a83e --- /dev/null +++ b/frontend/test-results/.last-run.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "failedTests": [ + "e4788778f47ce754c470-78366da2488a38e4bf74" + ] +} \ No newline at end of file diff --git a/frontend/test-results/upload-oss-upload-OSS-直传上传-登录---赛事创建页---上传封面图片到-OSS-chromium/error-context.md b/frontend/test-results/upload-oss-upload-OSS-直传上传-登录---赛事创建页---上传封面图片到-OSS-chromium/error-context.md new file mode 100644 index 0000000..7f847ed --- /dev/null +++ b/frontend/test-results/upload-oss-upload-OSS-直传上传-登录---赛事创建页---上传封面图片到-OSS-chromium/error-context.md @@ -0,0 +1,317 @@ +# Instructions + +- Following Playwright test failed. +- Explain why, be concise, respect Playwright best practices. +- Provide a snippet of code with the fix, if possible. + +# Test info + +- Name: upload\oss-upload.spec.ts >> OSS 直传上传 >> 登录 -> 赛事创建页 -> 上传封面图片到 OSS +- Location: e2e\upload\oss-upload.spec.ts:33:3 + +# Error details + +``` +Error: expect(received).not.toContain(expected) // indexOf + +Expected substring: not "/login" +Received string: "http://localhost:3000/super/login" +``` + +# Page snapshot + +```yaml +- generic [ref=e3]: + - complementary [ref=e4]: + - generic [ref=e6]: + - generic [ref=e7]: + - generic [ref=e8]: + - img "乐绘世界" [ref=e9] + - generic [ref=e10]: + - generic [ref=e11]: 乐绘世界 + - generic [ref=e12]: 创想活动乐园 + - menu [ref=e13]: + - generic [ref=e14] [cursor=pointer]: + - img "fund-view" [ref=e15]: + - img [ref=e16] + - generic [ref=e20]: 活动监管 + - list [ref=e21]: + - menuitem "unordered-list 全部活动" [ref=e22] [cursor=pointer]: + - img "unordered-list" [ref=e23]: + - img [ref=e24] + - generic [ref=e26]: 全部活动 + - menuitem "user-add 报名数据" [ref=e27] [cursor=pointer]: + - img "user-add" [ref=e28]: + - img [ref=e29] + - generic [ref=e31]: 报名数据 + - menuitem "file-text 作品数据" [ref=e32] [cursor=pointer]: + - img "file-text" [ref=e33]: + - img [ref=e34] + - generic [ref=e36]: 作品数据 + - menuitem "dashboard 评审进度" [ref=e37] [cursor=pointer]: + - img "dashboard" [ref=e38]: + - img [ref=e39] + - generic [ref=e41]: 评审进度 + - menuitem "trophy 活动成果" [ref=e42] [cursor=pointer]: + - img "trophy" [ref=e43]: + - img [ref=e44] + - generic [ref=e46]: 活动成果 + - generic [ref=e47] [cursor=pointer]: + - img "picture" [ref=e48]: + - img [ref=e49] + - generic [ref=e51]: 内容管理 + - menuitem "bank 机构管理" [ref=e52] [cursor=pointer]: + - img "bank" [ref=e53]: + - img [ref=e54] + - generic [ref=e56]: 机构管理 + - generic [ref=e57] [cursor=pointer]: + - img "team" [ref=e58]: + - img [ref=e59] + - generic [ref=e61]: 用户中心 + - generic [ref=e62] [cursor=pointer]: + - img "setting" [ref=e63]: + - img [ref=e64] + - generic [ref=e66]: 系统设置 + - generic [ref=e67]: + - generic [ref=e68] [cursor=pointer]: + - img [ref=e70] + - generic [ref=e71]: 超级管理员 + - img "menu-fold" [ref=e73] [cursor=pointer]: + - img [ref=e74] + - main [ref=e77]: + - generic [ref=e78]: + - generic [ref=e82]: 活动列表 + - generic [ref=e83]: + - generic [ref=e84] [cursor=pointer]: + - img "appstore" [ref=e86]: + - img [ref=e87] + - generic [ref=e89]: + - generic [ref=e90]: "0" + - generic [ref=e91]: 全部 + - generic [ref=e92] [cursor=pointer]: + - img "form" [ref=e94]: + - img [ref=e95] + - generic [ref=e98]: + - generic [ref=e99]: "0" + - generic [ref=e100]: 报名中 + - generic [ref=e101] [cursor=pointer]: + - img "edit" [ref=e103]: + - img [ref=e104] + - generic [ref=e106]: + - generic [ref=e107]: "0" + - generic [ref=e108]: 征稿中 + - generic [ref=e109] [cursor=pointer]: + - img "eye" [ref=e111]: + - img [ref=e112] + - generic [ref=e114]: + - generic [ref=e115]: "0" + - generic [ref=e116]: 评审中 + - generic [ref=e117] [cursor=pointer]: + - img "check-circle" [ref=e119]: + - img [ref=e120] + - generic [ref=e123]: + - generic [ref=e124]: "0" + - generic [ref=e125]: 已结束 + - generic [ref=e126] [cursor=pointer]: + - img "close-circle" [ref=e128]: + - img [ref=e129] + - generic [ref=e131]: + - generic [ref=e132]: "0" + - generic [ref=e133]: 未发布 + - generic [ref=e134]: + - generic [ref=e136]: + - generic "活动名称" [ref=e138]: "活动名称 :" + - textbox "请输入活动名称" [ref=e143] + - generic [ref=e146]: + - generic "活动阶段" [ref=e148]: "活动阶段 :" + - generic [ref=e152] [cursor=pointer]: + - generic [ref=e153]: + - combobox [ref=e155] + - generic: 全部阶段 + - generic: + - img: + - img + - generic [ref=e157]: + - generic "活动类型" [ref=e159]: "活动类型 :" + - generic [ref=e163] [cursor=pointer]: + - generic [ref=e164]: + - combobox [ref=e166] + - generic: 全部 + - generic: + - img: + - img + - generic [ref=e168]: + - generic "主办机构" [ref=e170]: "主办机构 :" + - generic [ref=e174] [cursor=pointer]: + - generic [ref=e175]: + - combobox [ref=e177] + - generic: 全部机构 + - generic: + - img: + - img + - generic [ref=e182]: + - button "search 搜索" [ref=e183] [cursor=pointer]: + - img "search" [ref=e184]: + - img [ref=e185] + - generic [ref=e187]: 搜索 + - button "reload 重置" [ref=e188] [cursor=pointer]: + - img "reload" [ref=e189]: + - img [ref=e190] + - generic [ref=e192]: 重置 + - table [ref=e199]: + - rowgroup [ref=e212]: + - row "序号 活动名称 主办机构 类型 阶段 可见范围 报名 作品 评审 活动时间 操作" [ref=e213]: + - columnheader "序号" [ref=e214] + - columnheader "活动名称" [ref=e215] + - columnheader "主办机构" [ref=e216] + - columnheader "类型" [ref=e217] + - columnheader "阶段" [ref=e218] + - columnheader "可见范围" [ref=e219] + - columnheader "报名" [ref=e220] + - columnheader "作品" [ref=e221] + - columnheader "评审" [ref=e222] + - columnheader "活动时间" [ref=e223] + - columnheader "操作" [ref=e224] + - rowgroup [ref=e225]: + - row "暂无数据" [ref=e226]: + - cell "暂无数据" [ref=e227]: + - generic [ref=e228]: + - img [ref=e230] + - paragraph [ref=e236]: 暂无数据 +``` + +# Test source + +```ts + 1 | import { test, expect } from '@playwright/test' + 2 | import path from 'path' + 3 | import { fileURLToPath } from 'url' + 4 | import fs from 'fs' + 5 | + 6 | const __filename = fileURLToPath(import.meta.url) + 7 | const __dirname = path.dirname(__filename) + 8 | + 9 | // 测试配置 + 10 | const TENANT_CODE = 'super' + 11 | const USERNAME = 'admin' + 12 | const PASSWORD = 'admin123' + 13 | + 14 | // 确保测试图片存在 + 15 | const FIXTURES_DIR = path.join(__dirname, 'fixtures') + 16 | const TEST_IMAGE_PATH = path.join(FIXTURES_DIR, 'test-upload.png') + 17 | if (!fs.existsSync(FIXTURES_DIR)) { + 18 | fs.mkdirSync(FIXTURES_DIR, { recursive: true }) + 19 | } + 20 | if (!fs.existsSync(TEST_IMAGE_PATH)) { + 21 | const pngData = Buffer.from( + 22 | 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + 23 | 'base64' + 24 | ) + 25 | fs.writeFileSync(TEST_IMAGE_PATH, pngData) + 26 | } + 27 | + 28 | test.describe('OSS 直传上传', () => { + 29 | + 30 | // 单独给这个测试更长的超时 + 31 | test.setTimeout(60000) + 32 | + 33 | test('登录 -> 赛事创建页 -> 上传封面图片到 OSS', async ({ page }) => { + 34 | // 监听网络请求,捕获 OSS 相关请求 + 35 | const ossTokenRequests: string[] = [] + 36 | const ossUploadRequests: string[] = [] + 37 | + 38 | page.on('request', (req) => { + 39 | const url = req.url() + 40 | if (url.includes('/upload/oss/token')) { + 41 | ossTokenRequests.push(url) + 42 | } + 43 | if (url.includes('aliyuncs.com')) { + 44 | ossUploadRequests.push(url) + 45 | } + 46 | }) + 47 | + 48 | // ========== 1. 登录 ========== + 49 | await page.goto(`/${TENANT_CODE}/login`) + 50 | await page.waitForLoadState('domcontentloaded') + 51 | + 52 | // 填写 Ant Design 表单 + 53 | await page.locator('input[placeholder="请输入用户名"]').fill(USERNAME) + 54 | await page.locator('input[placeholder="请输入密码"]').fill(PASSWORD) + 55 | + 56 | // 点击登录按钮(Ant Design a-button html-type="submit") + 57 | await page.locator('button.login-btn, button:has-text("登录"):visible').first().click() + 58 | + 59 | // 等待登录成功跳转 + 60 | await page.waitForURL(`**/${TENANT_CODE}/**`, { timeout: 15000 }) + 61 | await page.waitForLoadState('domcontentloaded') + 62 | + 63 | // 确认不在登录页了 + 64 | const currentUrl = page.url() +> 65 | expect(currentUrl).not.toContain('/login') + | ^ Error: expect(received).not.toContain(expected) // indexOf + 66 | console.log('[1] 登录成功, 当前页面:', currentUrl) + 67 | + 68 | // ========== 2. 进入赛事创建页 ========== + 69 | await page.goto(`/${TENANT_CODE}/contests/create`) + 70 | await page.waitForLoadState('domcontentloaded') + 71 | + 72 | // 等待表单页面加载 + 73 | await page.locator('input[placeholder*="活动名称"], input[placeholder*="名称"]').first().waitFor({ timeout: 10000 }) + 74 | console.log('[2] 赛事创建页加载成功') + 75 | + 76 | // ========== 3. 上传封面图片 ========== + 77 | // 直接用全局的 file input(Ant Design Upload 的隐藏 input) + 78 | const fileInputs = page.locator('input[type="file"]') + 79 | const fileCount = await fileInputs.count() + 80 | console.log('[3] 发现 file input 数量:', fileCount) + 81 | + 82 | // 第一个 file input 对应封面上传 + 83 | await fileInputs.first().setInputFiles(TEST_IMAGE_PATH) + 84 | + 85 | console.log('[3] 已选择封面文件,等待 OSS 上传...') + 86 | + 87 | // 等待网络请求完成 + 88 | await page.waitForTimeout(5000) + 89 | + 90 | // ========== 4. 验证 ========== + 91 | console.log('[4] OSS Token 请求数:', ossTokenRequests.length) + 92 | console.log('[4] OSS 上传请求数:', ossUploadRequests.length) + 93 | + 94 | // 验证:发出了 OSS Token 请求 + 95 | if (ossTokenRequests.length > 0) { + 96 | console.log('[4] Token 请求 URL:', ossTokenRequests[0]) + 97 | } + 98 | + 99 | // 验证:发出了 OSS 上传请求 + 100 | if (ossUploadRequests.length > 0) { + 101 | console.log('[4] 上传目标 URL:', ossUploadRequests[0]) + 102 | expect(ossUploadRequests[0]).toContain('aliyuncs.com') + 103 | } + 104 | + 105 | // 验证:检查页面上是否有上传成功的 UI 指示 + 106 | const successItems = page.locator('.ant-upload-list-item-done') + 107 | const errorItems = page.locator('.ant-upload-list-item-error') + 108 | const successCount = await successItems.count() + 109 | const errorCount = await errorItems.count() + 110 | + 111 | console.log('[4] 上传成功项:', successCount, '上传失败项:', errorCount) + 112 | + 113 | // 检查是否有错误提示消息 + 114 | const errorMsg = await page.locator('.ant-message-error').textContent().catch(() => '') + 115 | if (errorMsg) { + 116 | console.log('[4] 错误消息:', errorMsg) + 117 | } + 118 | + 119 | // 核心断言 + 120 | expect(ossTokenRequests.length).toBeGreaterThanOrEqual(1) + 121 | expect(ossUploadRequests.length).toBeGreaterThanOrEqual(1) + 122 | expect(errorCount).toBe(0) + 123 | + 124 | console.log('\n===== OSS 直传上传测试通过 =====') + 125 | console.log('OSS Token 请求:', ossTokenRequests.length) + 126 | console.log('OSS 上传请求:', ossUploadRequests.length) + 127 | console.log('上传目标:', ossUploadRequests[0] || 'N/A') + 128 | }) + 129 | }) + 130 | +``` \ No newline at end of file diff --git a/frontend/test-results/upload-oss-upload-OSS-直传上传-登录---赛事创建页---上传封面图片到-OSS-chromium/test-failed-1.png b/frontend/test-results/upload-oss-upload-OSS-直传上传-登录---赛事创建页---上传封面图片到-OSS-chromium/test-failed-1.png new file mode 100644 index 0000000..87b1454 Binary files /dev/null and b/frontend/test-results/upload-oss-upload-OSS-直传上传-登录---赛事创建页---上传封面图片到-OSS-chromium/test-failed-1.png differ diff --git a/lesingle-aicreate-backend-demo/aicreate-demo/src/main/java/com/example/leaidemo/LeaiDemoApplication.java b/lesingle-aicreate-backend-demo/aicreate-demo/src/main/java/com/example/leaidemo/LeaiDemoApplication.java index c695f07..3063cb9 100644 --- a/lesingle-aicreate-backend-demo/aicreate-demo/src/main/java/com/example/leaidemo/LeaiDemoApplication.java +++ b/lesingle-aicreate-backend-demo/aicreate-demo/src/main/java/com/example/leaidemo/LeaiDemoApplication.java @@ -526,7 +526,7 @@ public class LeaiDemoApplication { // 生成 HMAC 签名头 Map hmacHeaders = buildHmacHeaders(queryParams); - // 拼接 URL + // 拼接 URL(手动 URLEncode 参数值) StringBuilder queryString = new StringBuilder(); for (Map.Entry e : queryParams.entrySet()) { if (queryString.length() > 0) queryString.append("&"); @@ -536,7 +536,19 @@ public class LeaiDemoApplication { } String url = LEAI_API_URL + "/api/v1/query/works?" + queryString.toString(); - // 发起 HTTP GET + // ★★★ 重要: 如果你使用 Spring RestTemplate,URL 已经手动编码过, + // 必须传 URI 对象,否则 RestTemplate 会二次编码导致签名校验失败! + // 错误: restTemplate.getForObject(url, String.class) ← 会二次编码 %3A → %253A + // 正确: restTemplate.getForObject(URI.create(url), String.class) ← 不会二次编码 + // 或者: 不手动编码,用 UriComponentsBuilder 让 Spring 统一处理: + // URI uri = UriComponentsBuilder.fromHttpUrl(LEAI_API_URL + "/api/v1/query/works") + // .queryParam("orgId", ORG_ID) + // .queryParam("updatedAfter", updatedAfter) // 原始值,不要 encode + // .queryParam("page", "1").queryParam("size", "100") + // .build().encode().toUri(); + // restTemplate.getForObject(uri, String.class); + + // 本 Demo 使用 HttpURLConnection(JDK原生),不存在二次编码问题 String responseBody = httpGet(url, hmacHeaders); Map responseJson = parseJsonObject(responseBody); @@ -790,6 +802,11 @@ public class LeaiDemoApplication { /** * 调用 B2 GET /api/v1/query/work/{workId} 获取完整作品数据 * 使用 HMAC 认证 + * + * ★ 签名规则: queryParams 中放原始值(未编码),签名用原始值计算。 + * URL 拼接时再做 URLEncode。 + * 如果使用 RestTemplate,务必传 URI.create(url) 而非 String url, + * 否则会导致二次编码使签名校验失败(详见 B3 方法注释)。 */ private Map fetchB2Detail(String workId) throws Exception { Map queryParams = new TreeMap<>(); @@ -797,6 +814,7 @@ public class LeaiDemoApplication { Map hmacHeaders = buildHmacHeaders(queryParams); + // URL 手动编码(HttpURLConnection 不会二次编码,RestTemplate 会!) String url = LEAI_API_URL + "/api/v1/query/work/" + URLEncoder.encode(workId, "UTF-8") + "?orgId=" + URLEncoder.encode(ORG_ID, "UTF-8"); @@ -848,6 +866,10 @@ public class LeaiDemoApplication { * 用 & 拼接成 "key1=val1&key2=val2&..." 格式 * 签名算法: HMAC-SHA256(签名字符串, appSecret), hex 编码 * + * ★ 重要: queryParams 中的 value 必须是原始值(未 URLEncode),不是编码后的值! + * 例: "2026-04-08T03:48:38Z"(正确) 而非 "2026-04-08T03%3A48%3A38Z"(错误) + * 签名用原始值计算,URL 拼接时再做 URLEncode,两步分开。 + * * 返回4个 Header: * X-App-Key: 机构ID * X-Timestamp: 毫秒时间戳 diff --git a/oss-direct-upload-demo/README.md b/oss-direct-upload-demo/README.md new file mode 100644 index 0000000..03fc4c5 --- /dev/null +++ b/oss-direct-upload-demo/README.md @@ -0,0 +1,448 @@ +# 阿里云 OSS 前端直传 — 迁移文档 + +> 将本项目中的「阿里云 OSS 前端直传」功能提取为通用方案,方便迁移到其他项目。 + +--- + +## 目录 + +- [整体架构](#整体架构) +- [后端实现原理](#后端实现原理) +- [前端实现原理](#前端实现原理) +- [迁移步骤](#迁移步骤) + - [后端迁移(5 步)](#后端迁移5-步) + - [前端迁移(3 步)](#前端迁移3-步) +- [配置说明](#配置说明) +- [注意事项与安全建议](#注意事项与安全建议) +- [验证方法](#验证方法) +- [文件清单](#文件清单) + +--- + +## 整体架构 + +### 时序图 + +``` +┌────────┐ ┌────────┐ ┌──────────────┐ +│ 前端 │ │ 后端 │ │ 阿里云 OSS │ +└───┬────┘ └───┬────┘ └──────┬───────┘ + │ │ │ + │ ① GET /oss/token │ │ + │ ?fileName=图片.jpg│ │ + │ &dir=dev/avatar │ │ + │──────────────────>│ │ + │ │ │ + │ │ ② 生成签名 Token │ + │ │ - Policy (Base64) │ + │ │ - Signature (HMAC) │ + │ │ - Key (文件路径) │ + │ │ │ + │ ③ 返回 Token │ │ + │ {accessid, │ │ + │ policy, │ │ + │ signature, │ │ + │ key, host} │ │ + │<──────────────────│ │ + │ │ │ + │ ④ POST FormData 直传(不经过后端) │ + │ ┌──────────────────────────────────────┐│ + │ │ FormData: ││ + │ │ - success_action_status: 200 ││ + │ │ - OSSAccessKeyId: {accessid} ││ + │ │ - policy: {policy} ││ + │ │ - signature: {signature} ││ + │ │ - key: {key} ││ + │ │ - file: (二进制文件) ││ + │ └──────────────────────────────────────┘│ + │─────────────────────────────────────────>│ + │ │ │ + │ ⑤ 返回 200 OK │ │ + │<─────────────────────────────────────────│ + │ │ │ + │ ⑥ 使用文件 URL │ │ + │ https://bucket │ │ + │ .oss-cn-xxx │ │ + │ .aliyuncs.com │ │ + │ /dev/avatar/ │ │ + │ 2026-04-08/ │ │ + │ {uuid}.jpg │ │ + │─────────────────────────────────────────>│ + │ ⑦ 返回文件 │ │ + │<─────────────────────────────────────────│ + │ │ │ +``` + +### 核心优势 + +| 特性 | 说明 | +|------|------| +| **零后端带宽** | 文件直接传到 OSS,后端仅生成签名 Token | +| **安全性** | AccessKeySecret 只在后端,前端只拿到临时签名 | +| **高性能** | 利用阿里云 CDN 加速,上传速度快 | +| **可扩展** | 支持进度回调、取消上传、超时控制 | +| **环境隔离** | 自动添加 dev/test/prod 前缀,避免文件冲突 | + +--- + +## 后端实现原理 + +### PostObject 签名机制 + +阿里云 OSS 前端直传使用的是 **PostObject** 方式,核心是签名机制: + +#### 1. 构建 Policy + +```json +{ + "expiration": "2026-04-08T12:00:00.000Z", + "conditions": [ + ["eq", "$key", "dev/avatar/2026-04-08/a1b2c3d4.jpg"] + ] +} +``` + +- `expiration`:Token 过期时间(ISO 8601 格式) +- `conditions`:约束条件,这里限定只能上传到指定的 key(文件路径) + +#### 2. Base64 编码 Policy + +``` +eyJleHBpcmF0aW9uIjoiMjAyNi0wNC0wOFQxMjowMDowMC4wMDBaIiwiY29uZGl0aW9ucyI6W1siZXEiLCIka2V5IiwiZGV2L2F2YXRhci8yMDI2LTA0LTA4L2ExYjJjM2Q0LmpwZyJdXX0= +``` + +#### 3. HMAC-SHA1 签名 + +``` +signature = Base64(HMAC-SHA1(Base64(policy), accessKeySecret)) +``` + +#### 4. 返回给前端 + +```json +{ + "accessid": "LTAI5tXXXXXX", + "policy": "Base64 编码的 Policy", + "signature": "Base64 编码的签名", + "dir": "dev/avatar/", + "host": "https://your-bucket.oss-cn-hangzhou.aliyuncs.com", + "key": "dev/avatar/2026-04-08/a1b2c3d4.jpg", + "expire": 30 +} +``` + +--- + +## 前端实现原理 + +### FormData 直传 + +前端拿到 Token 后,使用 `FormData` 构造表单,直接 POST 到 OSS: + +```typescript +const formData = new FormData(); +formData.append("success_action_status", "200"); // 成功返回 200 +formData.append("OSSAccessKeyId", token.accessid); +formData.append("policy", token.policy); +formData.append("signature", token.signature); +formData.append("key", token.key); +formData.append("file", file); // file 必须为最后一个表单域 + +await axios.post(token.host, formData, { + headers: { "Content-Type": "multipart/form-data" }, + onUploadProgress: (e) => { /* 进度回调 */ }, +}); +``` + +### 环境目录自动隔离 + +`env.ts` 根据当前环境自动添加前缀: + +``` +开发环境: dev/avatar/2026-04-08/{uuid}.jpg +测试环境: test/avatar/2026-04-08/{uuid}.jpg +生产环境: prod/avatar/2026-04-08/{uuid}.jpg +``` + +--- + +## 迁移步骤 + +### 后端迁移(5 步) + +#### 第 1 步:添加 Maven 依赖 + +将 `pom-oss.xml` 中的依赖复制到你的 `pom.xml`: + +```xml + + com.aliyun.oss + aliyun-sdk-oss + 3.17.1 + +``` + +> 至少需要:`aliyun-sdk-oss`、`lombok`、`spring-boot-starter-web` + +#### 第 2 步:复制 Java 文件 + +将以下 4 个文件复制到你的项目中(根据包名调整): + +| 文件 | 放置位置 | 说明 | +|------|----------|------| +| `OssConfig.java` | `com/xxx/config/` | 配置类,绑定 yml 配置 | +| `OssTokenVo.java` | `com/xxx/vo/` | Token 响应对象 | +| `OssUtils.java` | `com/xxx/util/` | 核心工具类(签名 + CORS) | +| `FileUploadController.java` | `com/xxx/controller/` | API 接口 | + +> 可选:`OssCorsInitRunner.java` — 如果希望启动时自动配置 CORS + +#### 第 3 步:修改包名 + +全局搜索替换包名: + +``` +com.example.oss.config → 你的包名.config +com.example.oss.vo → 你的包名.vo +com.example.oss.util → 你的包名.util +com.example.oss.controller → 你的包名.controller +``` + +#### 第 4 步:添加配置 + +将 `application-oss.yml` 中的配置复制到你的 `application.yml`(或对应环境的配置文件): + +```yaml +aliyun: + oss: + endpoint: ${OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} + access-key-id: ${OSS_ACCESS_KEY_ID:your-key} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:your-secret} + bucket-name: ${OSS_BUCKET_NAME:your-bucket} + max-file-size: ${OSS_MAX_FILE_SIZE:10485760} + cors-enabled: ${OSS_CORS_ENABLED:true} + cors-allowed-origins: ${OSS_CORS_ORIGINS:http://localhost:5173} +``` + +#### 第 5 步:验证后端 + +启动项目后,访问以下接口验证: + +```bash +curl "http://localhost:8080/api/v1/files/oss/token?fileName=test.jpg&dir=avatar" +``` + +期望返回: + +```json +{ + "code": 200, + "data": { + "accessid": "LTAI5tXXXXXX", + "policy": "...", + "signature": "...", + "dir": "avatar/", + "host": "https://your-bucket.oss-cn-hangzhou.aliyuncs.com", + "key": "avatar/2026-04-08/xxxxxxxx.jpg", + "expire": 30 + } +} +``` + +--- + +### 前端迁移(3 步) + +#### 第 1 步:复制 TypeScript 文件 + +将以下 2 个文件复制到你的项目中: + +| 文件 | 放置位置 | 说明 | +|------|----------|------| +| `file.ts` | `src/api/` 或 `src/utils/` | 上传 API | +| `env.ts` | `src/utils/` | 环境工具 | + +#### 第 2 步:修改配置 + +在 `file.ts` 中修改后端 API 地址: + +```typescript +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; +``` + +如果使用自定义的 HTTP 封装(如项目中已有 `http` 工具),可以将 `axios.get` 替换为你的封装: + +```typescript +// 原始写法 +const response = await axios.get(`${API_BASE_URL}/v1/files/oss/token`, ...) + +// 替换为你的 HTTP 封装 +const response = await http.get(`/v1/files/oss/token`, ...) +``` + +#### 第 3 步:在组件中使用 + +```vue + +``` + +> 参考 `UploadDemo.vue` 查看完整的组件示例。 + +--- + +## 配置说明 + +### 阿里云 OSS Bucket 配置 + +#### 1. 创建 Bucket + +登录 [阿里云 OSS 控制台](https://oss.console.aliyun.com/),创建 Bucket: + +- **Bucket 名称**:自定义(如 `my-project-files`) +- **地域**:选择离用户最近的节点 +- **存储类型**:标准存储 +- **读写权限**:公共读(文件上传后可直接通过 URL 访问) + +#### 2. CORS 配置 + +如果后端 `cors-enabled` 设为 `true`,启动时会自动配置。否则需要手动配置: + +在 OSS 控制台 → Bucket → 权限管理 → 跨域设置: + +| 配置项 | 值 | +|--------|-----| +| 允许来源 | `http://localhost:5173`(开发)/ `https://your-domain.com`(生产) | +| 允许方法 | GET, POST, PUT, DELETE, HEAD | +| 允许 Headers | `*` | +| 暴露 Headers | ETag, x-oss-request-id | +| 缓存时间 | 600 秒 | + +#### 3. RAM 权限 + +建议为应用创建独立的 RAM 子账号,仅授予必要权限: + +```json +{ + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "oss:PutObject", + "oss:GetObject", + "oss:DeleteObject", + "oss:PutBucketCors" + ], + "Resource": [ + "acs:oss:*:*:your-bucket-name", + "acs:oss:*:*:your-bucket-name/*" + ] + } + ], + "Version": "1" +} +``` + +### 环境变量 + +| 变量名 | 必填 | 默认值 | 说明 | +|--------|------|--------|------| +| `OSS_ENDPOINT` | 是 | - | OSS Endpoint | +| `OSS_ACCESS_KEY_ID` | 是 | - | 阿里云 AccessKey ID | +| `OSS_ACCESS_KEY_SECRET` | 是 | - | 阿里云 AccessKey Secret | +| `OSS_BUCKET_NAME` | 是 | - | Bucket 名称 | +| `OSS_MAX_FILE_SIZE` | 否 | `10485760` (10MB) | 文件大小限制 | +| `OSS_CORS_ENABLED` | 否 | `true` | 是否自动配置 CORS | +| `OSS_CORS_ORIGINS` | 否 | `http://localhost:5173` | CORS 允许的来源 | + +--- + +## 注意事项与安全建议 + +### 安全 + +1. **AccessKeySecret 永远不要暴露给前端**:签名只在后端计算,前端只拿到签名结果 +2. **Token 有效期很短(30 秒)**:防止 Token 被盗用 +3. **Policy 中限制了 key**:前端只能上传到指定的路径,无法覆盖其他文件 +4. **使用 RAM 子账号**:不要使用主账号的 AccessKey +5. **生产环境使用环境变量**:不要在配置文件中硬编码密钥 + +### 注意事项 + +1. **文件名生成**:后端使用 `UUID + 原扩展名` 生成唯一文件名,避免文件名冲突 +2. **日期分区**:文件按日期分目录存储(如 `avatar/2026-04-08/`),方便管理 +3. **环境前缀**:前端自动添加 `dev/test/prod` 前缀,确保不同环境的文件互不干扰 +4. **CORS 配置**: + - 方式一:后端启动时自动配置(需 `oss:PutBucketCors` 权限) + - 方式二:在阿里云控制台手动配置(推荐生产环境) +5. **file 字段位置**:FormData 中 `file` 必须为最后一个字段,否则 OSS 可能报错 + +### 常见问题 + +| 问题 | 原因 | 解决方案 | +|------|------|----------| +| CORS 报错 | OSS Bucket 未配置 CORS | 开启 `cors-enabled` 或手动配置 | +| 403 Forbidden | 签名过期或错误 | 检查 AccessKey、系统时间 | +| InvalidPolicy | Policy 格式错误 | 确认 ISO 8601 时间格式以 Z 结尾 | +| 文件上传成功但无法访问 | Bucket 读写权限为私有 | 设置为公共读或使用签名 URL | +| FormData 报错 | file 字段不在最后 | 确保 `formData.append("file", ...)` 在最后 | + +--- + +## 验证方法 + +### 1. 验证后端 Token 接口 + +```bash +curl "http://localhost:8080/api/v1/files/oss/token?fileName=test.jpg&dir=test" +``` + +确认返回的 JSON 包含 `accessid`、`policy`、`signature`、`key`、`host` 字段。 + +### 2. 验证前端直传 + +使用 `UploadDemo.vue` 组件,选择一个图片文件,点击上传: +- 进度条应正常显示 +- 上传成功后显示文件 URL +- 在浏览器中访问 URL 可看到文件 + +### 3. 验证 CORS + +在浏览器控制台中检查: +- 上传请求不应出现 CORS 报错 +- OPTIONS 预检请求应返回 200 + +### 4. 验证环境隔离 + +分别在 development 和 production 环境上传文件,确认: +- 开发环境文件在 `dev/` 目录下 +- 生产环境文件在 `prod/` 目录下 + +--- + +## 文件清单 + +``` +docs/oss-direct-upload-demo/ +├── README.md ← 你正在看的文档 +├── backend/ +│ ├── OssConfig.java ← 配置类(绑定 yml 配置) +│ ├── OssTokenVo.java ← Token 响应对象 +│ ├── OssUtils.java ← 核心工具类(签名 + CORS) +│ ├── FileUploadController.java ← Controller(仅获取 Token 接口) +│ ├── OssCorsInitRunner.java ← 启动时自动配置 CORS(可选) +│ ├── application-oss.yml ← 配置文件示例 +│ └── pom-oss.xml ← Maven 依赖片段 +└── frontend/ + ├── file.ts ← 文件上传 API(类型 + 直传逻辑) + ├── env.ts ← 环境目录前缀工具 + └── UploadDemo.vue ← 上传组件 Demo +``` diff --git a/oss-direct-upload-demo/backend/FileUploadController.java b/oss-direct-upload-demo/backend/FileUploadController.java new file mode 100644 index 0000000..cbe4876 --- /dev/null +++ b/oss-direct-upload-demo/backend/FileUploadController.java @@ -0,0 +1,58 @@ +package com.example.oss.controller; + +import com.example.oss.util.OssUtils; +import com.example.oss.vo.OssTokenVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * 文件上传控制器 + *

+ * 仅提供 OSS 直传 Token 获取接口。 + * 实际文件上传由前端直接发送到阿里云 OSS,不经过后端。 + *

+ * + *

使用流程:

+ *
    + *
  1. 前端调用 GET /api/v1/files/oss/token 获取签名 Token
  2. + *
  3. 前端使用 FormData 将文件 + Token 直接 POST 到阿里云 OSS
  4. + *
+ */ +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +@Tag(name = "文件上传") +public class FileUploadController { + + private final OssUtils ossUtils; + + /** + * 获取阿里云 OSS 直传 Token + *

+ * 用于前端直传文件到 OSS,无需经过后端中转 + *

+ * + * @param fileName 原始文件名(如:图片.jpg) + * @param dir 目录前缀(可选,如:avatar, course/cover) + * @return OSS 直传 Token VO + */ + @GetMapping("/oss/token") + @Operation(summary = "获取阿里云 OSS 直传 Token") + public Map getOssToken( + @RequestParam("fileName") String fileName, + @RequestParam(value = "dir", required = false) String dir) { + + OssTokenVo token = ossUtils.generatePostObjectToken(fileName, dir); + + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "success"); + result.put("data", token); + return result; + } +} diff --git a/oss-direct-upload-demo/backend/OssConfig.java b/oss-direct-upload-demo/backend/OssConfig.java new file mode 100644 index 0000000..ee3df3d --- /dev/null +++ b/oss-direct-upload-demo/backend/OssConfig.java @@ -0,0 +1,79 @@ +package com.example.oss.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 阿里云 OSS 配置类 + *

+ * 从 application.yml 中读取 aliyun.oss 前缀的配置项 + *

+ */ +@Data +@Configuration +@ConfigurationProperties(prefix = "aliyun.oss") +public class OssConfig { + + /** + * OSS Endpoint(如:oss-cn-hangzhou.aliyuncs.com) + */ + private String endpoint; + + /** + * 访问密钥 ID + */ + private String accessKeyId; + + /** + * 访问密钥秘密 + */ + private String accessKeySecret; + + /** + * Bucket 名称 + */ + private String bucketName; + + /** + * 文件最大大小(字节),默认 10MB + */ + private Long maxFileSize = 10 * 1024 * 1024L; + + /** + * 是否在启动时自动配置 OSS Bucket CORS(解决前端直传跨域) + */ + private Boolean corsEnabled = false; + + /** + * CORS 允许的来源,逗号分隔(如:http://localhost:5173,https://example.com) + * 使用 * 表示允许所有来源 + */ + private String corsAllowedOrigins = "http://localhost:5173,http://localhost:5174"; + + /** + * 允许的文件扩展名 + */ + private String[] allowedExtensions = new String[]{ + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".mp4", ".avi", ".mov", ".wmv", + ".mp3", ".wav", + ".txt" + }; + + /** + * 获取完整访问路径(带 Bucket) + * + * @return 完整访问路径 + */ + public String getFullEndpoint() { + if (endpoint == null) { + return null; + } + if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) { + return endpoint; + } + return "https://" + bucketName + "." + endpoint; + } +} diff --git a/oss-direct-upload-demo/backend/OssCorsInitRunner.java b/oss-direct-upload-demo/backend/OssCorsInitRunner.java new file mode 100644 index 0000000..bd84142 --- /dev/null +++ b/oss-direct-upload-demo/backend/OssCorsInitRunner.java @@ -0,0 +1,40 @@ +package com.example.oss.config; + +import com.example.oss.util.OssUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * OSS Bucket CORS 初始化 + *

+ * 应用启动时自动配置 OSS 跨域规则,解决前端直传跨域问题。 + * 需在配置中开启:aliyun.oss.cors-enabled=true + *

+ * + *

工作原理:

+ *
    + *
  1. Spring Boot 启动完成后自动执行
  2. + *
  3. 读取 aliyun.oss.cors-enabled 配置
  4. + *
  5. 如果开启,调用 OSS API 设置 Bucket 的 CORS 规则
  6. + *
  7. 如果失败(如权限不足),仅打印警告,不影响应用启动
  8. + *
+ * + *

也可以不使用此自动配置,改为在阿里云控制台手动设置 CORS。

+ */ +@Slf4j +@Component +@Order(100) // 较晚执行,确保其他组件已就绪 +@RequiredArgsConstructor +public class OssCorsInitRunner implements ApplicationRunner { + + private final OssUtils ossUtils; + + @Override + public void run(ApplicationArguments args) { + ossUtils.configureBucketCors(); + } +} diff --git a/oss-direct-upload-demo/backend/OssTokenVo.java b/oss-direct-upload-demo/backend/OssTokenVo.java new file mode 100644 index 0000000..e499f24 --- /dev/null +++ b/oss-direct-upload-demo/backend/OssTokenVo.java @@ -0,0 +1,54 @@ +package com.example.oss.vo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 阿里云 OSS 直传 Token 响应 VO + *

+ * 用于前端直传阿里云 OSS(PostObject 方式) + *

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OssTokenVo { + + /** + * OSS 访问 ID(AccessKeyId) + */ + private String accessid; + + /** + * 合法性策略(Base64 编码的 Policy) + */ + private String policy; + + /** + * 签名信息 + */ + private String signature; + + /** + * 上传目录前缀 + */ + private String dir; + + /** + * OSS 上传地址(https://bucketname.endpoint) + */ + private String host; + + /** + * 完整文件路径(dir + fileName) + */ + private String key; + + /** + * 过期时间(秒) + */ + private Integer expire; +} diff --git a/oss-direct-upload-demo/backend/OssUtils.java b/oss-direct-upload-demo/backend/OssUtils.java new file mode 100644 index 0000000..e02576d --- /dev/null +++ b/oss-direct-upload-demo/backend/OssUtils.java @@ -0,0 +1,257 @@ +package com.example.oss.util; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.SetBucketCORSRequest; +import com.example.oss.config.OssConfig; +import com.example.oss.vo.OssTokenVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +/** + * 阿里云 OSS 工具类(前端直传专用) + *

+ * 仅包含前端直传所需的核心方法: + * - generatePostObjectToken: 生成 PostObject 直传 Token + * - configureBucketCors: 配置 Bucket CORS 规则 + *

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OssUtils { + + private final OssConfig ossConfig; + + /** + * 获取 OSS 客户端 + * + * @return OSS 客户端 + */ + private OSS getOssClient() { + return new OSSClientBuilder().build( + ossConfig.getEndpoint(), + ossConfig.getAccessKeyId(), + ossConfig.getAccessKeySecret() + ); + } + + // ==================== 前端直传核心方法 ==================== + + /** + * 生成阿里云 OSS PostObject 直传 Token + *

+ * 用于前端直传文件到 OSS,无需经过后端中转。 + * 前端拿到此 Token 后,通过 FormData + POST 方式直接上传到 OSS。 + *

+ * + *

签名流程:

+ *
    + *
  1. 构建 Policy JSON(包含过期时间和 key 约束)
  2. + *
  3. Base64 编码 Policy
  4. + *
  5. 使用 AccessKeySecret + HMAC-SHA1 对 Base64(Policy) 计算签名
  6. + *
+ * + * @param fileName 原始文件名(如:图片.jpg) + * @param dir 目录前缀(可选,如:avatar, course/cover) + * @return OSS 直传 Token VO + */ + public OssTokenVo generatePostObjectToken(String fileName, String dir) { + // 校验文件名 + if (fileName == null || fileName.isEmpty()) { + throw new IllegalArgumentException("文件名不能为空"); + } + + // 校验文件扩展名 + if (!isAllowedExtension(fileName)) { + throw new IllegalArgumentException("不支持的文件类型:" + fileName); + } + + // 获取文件扩展名 + String extension = getFileExtension(fileName); + + // 生成唯一文件名(UUID + 原扩展名) + String uniqueFilename = UUID.randomUUID().toString().replace("-", "") + extension; + + // 生成日期路径(如:2026-03-16/) + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + "/"; + + // 组合存储路径 + String objectKey; + if (dir != null && !dir.isEmpty()) { + // 确保 dir 以/结尾 + if (!dir.endsWith("/")) { + dir = dir + "/"; + } + objectKey = dir + datePath + uniqueFilename; + } else { + objectKey = datePath + uniqueFilename; + } + + // 设置过期时间(30 秒) + int expire = 30; + long expiration = System.currentTimeMillis() + expire * 1000L; + + // 构建 PostPolicy + String policy = buildPostPolicy(objectKey, expiration); + + // 计算签名 + String signature = computeSignature(policy, ossConfig.getAccessKeySecret()); + + // 构建上传地址 + String host = "https://" + ossConfig.getBucketName() + "." + ossConfig.getEndpoint(); + + // 返回 Token VO + return OssTokenVo.builder() + .accessid(ossConfig.getAccessKeyId()) + .policy(policy) + .signature(signature) + .dir(dir != null ? dir : "") + .host(host) + .key(objectKey) + .expire(expire) + .build(); + } + + // ==================== CORS 配置 ==================== + + /** + * 配置 OSS Bucket CORS 规则,解决前端直传跨域问题 + *

+ * 需确保 OSS 账号有 oss:PutBucketCors 权限。 + * 也可在阿里云控制台手动配置。 + *

+ */ + public void configureBucketCors() { + if (!Boolean.TRUE.equals(ossConfig.getCorsEnabled())) { + return; + } + OSS ossClient = getOssClient(); + try { + SetBucketCORSRequest request = new SetBucketCORSRequest(ossConfig.getBucketName()); + SetBucketCORSRequest.CORSRule rule = new SetBucketCORSRequest.CORSRule(); + + // 允许的来源(开发:localhost,生产:需配置实际域名) + String origins = ossConfig.getCorsAllowedOrigins(); + if (origins == null || origins.isBlank()) { + origins = "http://localhost:5173,http://localhost:5174"; + } + rule.setAllowedOrigins(Arrays.asList(origins.trim().split("\s*,\s*"))); + + // 允许的方法:POST(直传)、GET、PUT、DELETE、HEAD + rule.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "HEAD")); + + // 允许的请求头(* 表示全部,PostObject 需要) + rule.setAllowedHeaders(Arrays.asList("*")); + + // 暴露给前端的响应头 + rule.setExposeHeaders(Arrays.asList("ETag", "x-oss-request-id")); + + // 预检请求缓存时间(秒) + rule.setMaxAgeSeconds(600); + + request.setCorsRules(List.of(rule)); + ossClient.setBucketCORS(request); + log.info("OSS Bucket CORS 配置成功,允许来源: {}", origins); + } catch (Exception e) { + log.warn("OSS Bucket CORS 配置失败(可在阿里云控制台手动配置): {}", e.getMessage()); + } finally { + ossClient.shutdown(); + } + } + + // ==================== 私有辅助方法 ==================== + + /** + * 构建 PostPolicy + * + * @param objectKey 对象键 + * @param expiration 过期时间戳 + * @return Base64 编码的 Policy + */ + private String buildPostPolicy(String objectKey, long expiration) { + // ISO 8601 GMT 格式(阿里云要求 YYYY-MM-DDTHH:mm:ss.sssZ,必须以 Z 结尾) + String expireTime = java.time.Instant.ofEpochMilli(expiration).toString(); + + // 构建 Policy JSON + String policyJson = String.format( + "{\"expiration\":\"%s\",\"conditions\":[[\"eq\",\"$key\",\"%s\"]]}", + expireTime, + objectKey + ); + + // Base64 编码 + return Base64.getEncoder().encodeToString(policyJson.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 计算签名 + * + * @param policy Base64 编码的 Policy + * @param accessKeySecret OSS 访问密钥 + * @return 签名(Base64 编码) + */ + private String computeSignature(String policy, String accessKeySecret) { + try { + // 使用 HMAC-SHA1 算法 + Mac mac = Mac.getInstance("HmacSHA1"); + SecretKeySpec keySpec = new SecretKeySpec( + accessKeySecret.getBytes(StandardCharsets.UTF_8), + "HmacSHA1" + ); + mac.init(keySpec); + + // 计算签名 + byte[] signData = mac.doFinal(policy.getBytes(StandardCharsets.UTF_8)); + + // Base64 编码 + return Base64.getEncoder().encodeToString(signData); + } catch (Exception e) { + log.error("计算 OSS 签名失败:{}", e.getMessage(), e); + throw new RuntimeException("计算 OSS 签名失败:" + e.getMessage(), e); + } + } + + /** + * 检查文件扩展名是否允许 + * + * @param filename 文件名 + * @return 是否允许 + */ + private boolean isAllowedExtension(String filename) { + if (filename == null || filename.isEmpty()) { + return false; + } + String extension = getFileExtension(filename).toLowerCase(); + return Arrays.stream(ossConfig.getAllowedExtensions()) + .anyMatch(allowed -> allowed.toLowerCase().equals(extension)); + } + + /** + * 获取文件扩展名 + * + * @param filename 文件名 + * @return 扩展名(包含点) + */ + private String getFileExtension(String filename) { + if (filename == null || filename.isEmpty()) { + return ""; + } + int lastDot = filename.lastIndexOf("."); + if (lastDot == -1) { + return ""; + } + return filename.substring(lastDot); + } +} diff --git a/oss-direct-upload-demo/backend/application-oss.yml b/oss-direct-upload-demo/backend/application-oss.yml new file mode 100644 index 0000000..570a3fe --- /dev/null +++ b/oss-direct-upload-demo/backend/application-oss.yml @@ -0,0 +1,32 @@ +# ============================================================ +# 阿里云 OSS 配置片段 +# ============================================================ +# 将以下内容复制到你的 application.yml(或 application-dev.yml)中 +# 所有敏感配置建议使用环境变量,不要硬编码 +# ============================================================ + +aliyun: + oss: + # OSS Endpoint(不带 http:// 前缀) + endpoint: ${OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} + + # 阿里云 AccessKey(建议使用环境变量) + access-key-id: ${OSS_ACCESS_KEY_ID:your-access-key-id} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:your-access-key-secret} + + # Bucket 名称 + bucket-name: ${OSS_BUCKET_NAME:your-bucket-name} + + # 文件最大大小(字节),默认 10MB + max-file-size: ${OSS_MAX_FILE_SIZE:10485760} + + # 前端直传跨域:启动时自动配置 OSS CORS + # 设为 true 时,应用启动会自动调用 OSS API 设置 CORS 规则 + # 设为 false 时,需要在阿里云控制台手动配置 + cors-enabled: ${OSS_CORS_ENABLED:true} + + # CORS 允许的来源,逗号分隔 + # 开发环境:http://localhost:5173,http://localhost:5174 + # 生产环境:https://your-domain.com + # 使用 * 表示允许所有来源(仅建议开发环境) + cors-allowed-origins: ${OSS_CORS_ORIGINS:http://localhost:5173,http://localhost:5174} diff --git a/oss-direct-upload-demo/backend/pom-oss.xml b/oss-direct-upload-demo/backend/pom-oss.xml new file mode 100644 index 0000000..8fd1715 --- /dev/null +++ b/oss-direct-upload-demo/backend/pom-oss.xml @@ -0,0 +1,38 @@ + + + + + + + + + com.aliyun.oss + aliyun-sdk-oss + 3.17.1 + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + diff --git a/oss-direct-upload-demo/frontend/UploadDemo.vue b/oss-direct-upload-demo/frontend/UploadDemo.vue new file mode 100644 index 0000000..959364b --- /dev/null +++ b/oss-direct-upload-demo/frontend/UploadDemo.vue @@ -0,0 +1,210 @@ + + + + + + diff --git a/oss-direct-upload-demo/frontend/env.ts b/oss-direct-upload-demo/frontend/env.ts new file mode 100644 index 0000000..dd14b32 --- /dev/null +++ b/oss-direct-upload-demo/frontend/env.ts @@ -0,0 +1,59 @@ +/** + * 环境工具函数 + * + * 提供环境相关的配置,用于自动为 OSS 路径添加环境前缀(dev/test/prod)。 + * 这样不同环境的文件会存储在不同的目录下,互不干扰。 + */ + +/** + * OSS 环境前缀映射 + * + * 根据你的项目环境变量名修改此映射。 + * Vite 默认使用 import.meta.env.MODE,值通常为 development / test / production。 + */ +const OSS_ENV_PREFIX_MAP: Record = { + development: "dev", + test: "test", + production: "prod", +}; + +/** + * 获取当前 Vite 环境 + */ +export function getViteEnv(): string { + return import.meta.env.MODE || "development"; +} + +/** + * 获取 OSS 环境前缀 + * + * @returns OSS 环境前缀(dev/test/prod) + */ +export function getOssEnvPrefix(): string { + const env = getViteEnv(); + return OSS_ENV_PREFIX_MAP[env] || "dev"; +} + +/** + * 构建完整的 OSS 目录路径 + * + * @param bizDir 业务目录(如:avatar, course/cover) + * @returns 完整目录路径(如:dev/avatar, test/course/cover) + * + * @example + * buildOssDirPath("avatar") // 开发环境 → "dev/avatar" + * buildOssDirPath("course/cover") // 测试环境 → "test/course/cover" + * buildOssDirPath() // 生产环境 → "prod" + */ +export function buildOssDirPath(bizDir?: string): string { + const envPrefix = getOssEnvPrefix(); + + if (!bizDir) { + return envPrefix; + } + + // 移除 bizDir 开头可能存在的环境前缀,避免重复 + const cleanBizDir = bizDir.replace(/^(dev|test|prod)\//, ""); + + return `${envPrefix}/${cleanBizDir}`; +} diff --git a/oss-direct-upload-demo/frontend/file.ts b/oss-direct-upload-demo/frontend/file.ts new file mode 100644 index 0000000..5f55728 --- /dev/null +++ b/oss-direct-upload-demo/frontend/file.ts @@ -0,0 +1,162 @@ +import axios from "axios"; +import { buildOssDirPath } from "./env"; + +// ==================== 类型定义 ==================== + +/** + * OSS 直传 Token 响应 + */ +export interface OssToken { + /** OSS 访问 ID */ + accessid: string; + /** Base64 编码的 Policy */ + policy: string; + /** 签名 */ + signature: string; + /** 上传目录前缀 */ + dir: string; + /** OSS 上传地址(https://bucketname.endpoint) */ + host: string; + /** 完整文件路径(dir + 日期路径 + UUID文件名) */ + key: string; + /** 过期时间(秒) */ + expire: number; +} + +/** + * 上传结果 + */ +export interface UploadResult { + success: boolean; + filePath: string; + fileName: string; + fileSize: number; + mimeType: string; +} + +// ==================== 配置 ==================== + +/** + * 后端 API 基础地址 + * 根据你的项目修改此值 + */ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; + +// ==================== 核心方法 ==================== + +/** + * 获取阿里云 OSS 直传 Token + * + * @param fileName 文件名(如:图片.jpg) + * @param dir 业务目录(如:avatar, course/cover),会自动添加环境前缀 + * @returns OSS 直传 Token + */ +export async function getOssToken( + fileName: string, + dir?: string, +): Promise { + // 自动添加环境前缀(dev/test/prod) + const fullDir = buildOssDirPath(dir); + + const response = await axios.get<{ code: number; data: OssToken }>( + `${API_BASE_URL}/v1/files/oss/token`, + { + params: { fileName, dir: fullDir }, + }, + ); + return response.data.data; +} + +/** + * 直接上传文件到阿里云 OSS + * + * 使用 FormData + POST 方式直传,无需经过后端中转。 + * 支持:进度回调、取消上传、超时设置。 + * + * @param file 要上传的文件 + * @param token OSS Token(通过 getOssToken 获取) + * @param options 可选配置 + * @returns 上传后的文件 URL + */ +export async function uploadToOss( + file: File, + token: OssToken, + options?: { + /** 上传进度回调(0-100) */ + onProgress?: (percent: number) => void; + /** 取消信号 */ + signal?: AbortSignal; + /** 超时时间(毫秒),默认 5 分钟 */ + timeout?: number; + }, +): Promise<{ url: string }> { + const opts = options ?? {}; + const formData = new FormData(); + + // 按照阿里云 OSS PostObject 要求构造表单 + // 注意:file 必须为最后一个表单域 + formData.append("success_action_status", "200"); // 成功时返回 200 + formData.append("OSSAccessKeyId", token.accessid); + formData.append("policy", token.policy); + formData.append("signature", token.signature); + formData.append("key", token.key); + formData.append("file", file); // file 必须为最后一个表单域 + + await axios.post(token.host, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + timeout: opts.timeout ?? 1000 * 60 * 5, // 默认 5 分钟 + signal: opts.signal, + onUploadProgress: (progressEvent) => { + if (opts.onProgress) { + const percent = + progressEvent.progress != null + ? progressEvent.progress * 100 + : progressEvent.total + ? (progressEvent.loaded * 100) / progressEvent.total + : 0; + opts.onProgress(Math.round(percent)); + } + }, + }); + + return { + url: `${token.host}/${token.key}`, + }; +} + +/** + * 一站式上传文件(获取 Token + 直传 OSS) + * + * @param file 要上传的文件 + * @param dir 业务目录(如:avatar, course/cover) + * @param options 可选配置 + * @returns 上传结果 + */ +export async function uploadFile( + file: File, + dir?: string, + options?: { + onProgress?: (percent: number) => void; + signal?: AbortSignal; + }, +): Promise { + // 1. 获取 OSS 直传 Token + const token = await getOssToken(file.name, dir); + + // 2. 直传到 OSS + await uploadToOss(file, token, { + onProgress: options?.onProgress, + signal: options?.signal, + }); + + // 3. 返回结果 + return { + success: true, + filePath: `${token.host}/${token.key}`, + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + }; +}