feat: OSS 客户端直传改造(STS Token 签发 + 前端直传 + CORS 自动配置)
后端新增 OssUtils/OssTokenVo/OssCorsInitRunner,通过 STS 临时凭证实现客户端直传 OSS; 前端 upload API 适配直传流程,赛事创建/作品提交/作业/富文本编辑器均已切换; 多环境(dev/test/prod) OSS 配置补全;新增 oss-direct-upload-demo 示例项目及 E2E 测试。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f036eb81f
commit
b9ed5e17c6
@ -26,7 +26,7 @@
|
|||||||
<mapstruct.version>1.5.5.Final</mapstruct.version>
|
<mapstruct.version>1.5.5.Final</mapstruct.version>
|
||||||
<hutool.version>5.8.32</hutool.version>
|
<hutool.version>5.8.32</hutool.version>
|
||||||
<fastjson2.version>2.0.53</fastjson2.version>
|
<fastjson2.version>2.0.53</fastjson2.version>
|
||||||
<cos.version>5.6.227</cos.version>
|
<aliyun-oss.version>3.17.1</aliyun-oss.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@ -130,11 +130,11 @@
|
|||||||
<version>${fastjson2.version}</version>
|
<version>${fastjson2.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 腾讯云 COS -->
|
<!-- 阿里云 OSS -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.qcloud</groupId>
|
<groupId>com.aliyun.oss</groupId>
|
||||||
<artifactId>cos_api</artifactId>
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
<version>${cos.version}</version>
|
<version>${aliyun-oss.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Lombok -->
|
<!-- Lombok -->
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package com.competition.modules.leai.service;
|
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.common.exception.BusinessException;
|
||||||
import com.competition.modules.leai.config.LeaiConfig;
|
import com.competition.modules.leai.config.LeaiConfig;
|
||||||
import com.competition.modules.leai.util.LeaiUtil;
|
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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.*;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
import javax.crypto.Mac;
|
import javax.crypto.Mac;
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@ -24,14 +23,15 @@ import java.util.*;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 乐读派 API 客户端
|
* 乐读派 API 客户端
|
||||||
* 使用 RestTemplate + Jackson 对接乐读派后端
|
* 使用 Hutool HttpRequest + Jackson 对接乐读派后端
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class LeaiApiClient {
|
public class LeaiApiClient {
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private static final int TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
private final LeaiConfig leaiConfig;
|
private final LeaiConfig leaiConfig;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@ -57,13 +57,17 @@ public class LeaiApiClient {
|
|||||||
body.put("phone", phone);
|
body.put("phone", phone);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
String jsonBody = objectMapper.writeValueAsString(body);
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
|
|
||||||
HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
|
HttpResponse httpResponse = HttpRequest.post(url)
|
||||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
|
.body(jsonBody)
|
||||||
|
.contentType("application/json")
|
||||||
|
.timeout(TIMEOUT_MS)
|
||||||
|
.execute();
|
||||||
|
|
||||||
Map<String, Object> result = objectMapper.readValue(response.getBody(),
|
String responseBody = httpResponse.body();
|
||||||
|
|
||||||
|
Map<String, Object> result = objectMapper.readValue(responseBody,
|
||||||
new TypeReference<Map<String, Object>>() {});
|
new TypeReference<Map<String, Object>>() {});
|
||||||
|
|
||||||
int code = LeaiUtil.toInt(result.get("code"), 0);
|
int code = LeaiUtil.toInt(result.get("code"), 0);
|
||||||
@ -99,24 +103,23 @@ public class LeaiApiClient {
|
|||||||
*/
|
*/
|
||||||
public Map<String, Object> fetchWorkDetail(String workId) {
|
public Map<String, Object> fetchWorkDetail(String workId) {
|
||||||
// orgId 对应本项目租户 code(tenant_code)
|
// orgId 对应本项目租户 code(tenant_code)
|
||||||
Map<String, String> queryParams = new TreeMap<>();
|
Map<String, Object> queryParams = new TreeMap<>();
|
||||||
queryParams.put("orgId", leaiConfig.getOrgId());
|
queryParams.put("orgId", leaiConfig.getOrgId());
|
||||||
|
|
||||||
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String url = leaiConfig.getApiUrl() + "/api/v1/query/work/"
|
String url = leaiConfig.getApiUrl() + "/api/v1/query/work/" + workId;
|
||||||
+ URLEncoder.encode(workId, StandardCharsets.UTF_8)
|
|
||||||
+ "?orgId=" + URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8); // 租户 code
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpResponse httpResponse = HttpRequest.get(url)
|
||||||
hmacHeaders.forEach(headers::set);
|
.form(queryParams)
|
||||||
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
|
.addHeaders(hmacHeaders)
|
||||||
|
.timeout(TIMEOUT_MS)
|
||||||
|
.execute();
|
||||||
|
|
||||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
String responseBody = httpResponse.body();
|
||||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
|
||||||
|
|
||||||
Map<String, Object> result = objectMapper.readValue(response.getBody(),
|
Map<String, Object> result = objectMapper.readValue(responseBody,
|
||||||
new TypeReference<Map<String, Object>>() {});
|
new TypeReference<Map<String, Object>>() {});
|
||||||
|
|
||||||
int code = LeaiUtil.toInt(result.get("code"), 0);
|
int code = LeaiUtil.toInt(result.get("code"), 0);
|
||||||
@ -141,7 +144,7 @@ public class LeaiApiClient {
|
|||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> queryWorks(String updatedAfter) {
|
public List<Map<String, Object>> queryWorks(String updatedAfter) {
|
||||||
// orgId 对应本项目租户 code(tenant_code)
|
// orgId 对应本项目租户 code(tenant_code)
|
||||||
Map<String, String> queryParams = new TreeMap<>();
|
Map<String, Object> queryParams = new TreeMap<>();
|
||||||
queryParams.put("orgId", leaiConfig.getOrgId());
|
queryParams.put("orgId", leaiConfig.getOrgId());
|
||||||
queryParams.put("updatedAfter", updatedAfter);
|
queryParams.put("updatedAfter", updatedAfter);
|
||||||
queryParams.put("page", "1");
|
queryParams.put("page", "1");
|
||||||
@ -150,24 +153,22 @@ public class LeaiApiClient {
|
|||||||
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
StringBuilder queryString = new StringBuilder();
|
String url = leaiConfig.getApiUrl() + "/api/v1/query/works";
|
||||||
for (Map.Entry<String, String> 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?" + queryString;
|
log.info("[乐读派] B3查询请求: url={}, params={}, headers={}", url, queryParams, hmacHeaders);
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpResponse httpResponse = HttpRequest.get(url)
|
||||||
hmacHeaders.forEach(headers::set);
|
.form(queryParams)
|
||||||
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
|
.addHeaders(hmacHeaders)
|
||||||
|
.timeout(TIMEOUT_MS)
|
||||||
|
.execute();
|
||||||
|
|
||||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
String responseBody = httpResponse.body();
|
||||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
|
int status = httpResponse.getStatus();
|
||||||
|
|
||||||
Map<String, Object> result = objectMapper.readValue(response.getBody(),
|
log.info("[乐读派] B3查询响应: status={}, body={}", status, responseBody);
|
||||||
|
|
||||||
|
Map<String, Object> result = objectMapper.readValue(responseBody,
|
||||||
new TypeReference<Map<String, Object>>() {});
|
new TypeReference<Map<String, Object>>() {});
|
||||||
|
|
||||||
int code = LeaiUtil.toInt(result.get("code"), 0);
|
int code = LeaiUtil.toInt(result.get("code"), 0);
|
||||||
@ -198,11 +199,12 @@ public class LeaiApiClient {
|
|||||||
/**
|
/**
|
||||||
* 生成 HMAC 签名请求头
|
* 生成 HMAC 签名请求头
|
||||||
*/
|
*/
|
||||||
public Map<String, String> buildHmacHeaders(Map<String, String> queryParams) {
|
public Map<String, String> buildHmacHeaders(Map<String, ?> queryParams) {
|
||||||
String ts = String.valueOf(System.currentTimeMillis());
|
String ts = String.valueOf(System.currentTimeMillis());
|
||||||
String nonce = Long.toHexString(System.currentTimeMillis()) + Long.toHexString(System.nanoTime());
|
String nonce = Long.toHexString(System.currentTimeMillis()) + Long.toHexString(System.nanoTime());
|
||||||
|
|
||||||
TreeMap<String, String> allParams = new TreeMap<>(queryParams);
|
TreeMap<String, String> allParams = new TreeMap<>();
|
||||||
|
queryParams.forEach((k, v) -> allParams.put(k, String.valueOf(v)));
|
||||||
allParams.put("timestamp", ts);
|
allParams.put("timestamp", ts);
|
||||||
allParams.put("nonce", nonce);
|
allParams.put("nonce", nonce);
|
||||||
|
|
||||||
@ -212,8 +214,15 @@ public class LeaiApiClient {
|
|||||||
signStr.append(entry.getKey()).append("=").append(entry.getValue());
|
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());
|
String sig = hmacSha256(signStr.toString(), leaiConfig.getAppSecret());
|
||||||
|
|
||||||
|
log.debug("[乐读派签名] 签名结果: {}", sig);
|
||||||
|
log.debug("[乐读派签名] 请求头: X-App-Key={}, X-Timestamp={}, X-Nonce={}", leaiConfig.getOrgId(), ts, nonce);
|
||||||
|
|
||||||
Map<String, String> headers = new LinkedHashMap<>();
|
Map<String, String> headers = new LinkedHashMap<>();
|
||||||
headers.put("X-App-Key", leaiConfig.getOrgId()); // X-App-Key 即租户 code(tenant_code)
|
headers.put("X-App-Key", leaiConfig.getOrgId()); // X-App-Key 即租户 code(tenant_code)
|
||||||
headers.put("X-Timestamp", ts);
|
headers.put("X-Timestamp", ts);
|
||||||
|
|||||||
@ -26,7 +26,7 @@ public class LeaiReconcileTask {
|
|||||||
private final ILeaiSyncService leaiSyncService;
|
private final ILeaiSyncService leaiSyncService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每30分钟执行一次,初始延迟60秒
|
* 每1分钟执行一次(测试阶段,正式环境改为30分钟),初始延迟10秒
|
||||||
*/
|
*/
|
||||||
@Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000)
|
@Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000)
|
||||||
public void reconcile() {
|
public void reconcile() {
|
||||||
|
|||||||
@ -4,18 +4,51 @@ import lombok.Data;
|
|||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阿里云 OSS 配置
|
||||||
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Configuration
|
@Configuration
|
||||||
@ConfigurationProperties(prefix = "oss")
|
@ConfigurationProperties(prefix = "aliyun.oss")
|
||||||
public class OssConfig {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 初始化
|
||||||
|
* <p>
|
||||||
|
* 应用启动时自动配置 OSS 跨域规则,解决前端直传跨域问题。
|
||||||
|
* 需在配置中开启:aliyun.oss.cors-enabled=true
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(100)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OssCorsInitRunner implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final OssUtils ossUtils;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
ossUtils.configureBucketCors();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,13 +2,13 @@ package com.competition.modules.oss.controller;
|
|||||||
|
|
||||||
import com.competition.common.result.Result;
|
import com.competition.common.result.Result;
|
||||||
import com.competition.modules.oss.service.OssService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
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.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -20,8 +20,9 @@ import java.util.Map;
|
|||||||
public class UploadController {
|
public class UploadController {
|
||||||
|
|
||||||
private final OssService ossService;
|
private final OssService ossService;
|
||||||
|
private final OssUtils ossUtils;
|
||||||
|
|
||||||
@Operation(summary = "上传文件")
|
@Operation(summary = "服务端上传文件(向后兼容)")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public Result<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
|
public Result<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
|
||||||
String url = ossService.uploadFile(file);
|
String url = ossService.uploadFile(file);
|
||||||
@ -31,4 +32,14 @@ public class UploadController {
|
|||||||
"size", file.getSize()
|
"size", file.getSize()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public
|
||||||
|
@Operation(summary = "获取 OSS 直传 Token")
|
||||||
|
@GetMapping("/oss/token")
|
||||||
|
public Result<OssTokenVo> getOssToken(
|
||||||
|
@RequestParam("fileName") String fileName,
|
||||||
|
@RequestParam(value = "dir", required = false) String dir) {
|
||||||
|
OssTokenVo token = ossUtils.generatePostObjectToken(fileName, dir);
|
||||||
|
return Result.success(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,100 +1,29 @@
|
|||||||
package com.competition.modules.oss.service;
|
package com.competition.modules.oss.service;
|
||||||
|
|
||||||
import com.competition.modules.oss.config.OssConfig;
|
import com.competition.modules.oss.util.OssUtils;
|
||||||
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 lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.File;
|
/**
|
||||||
import java.io.IOException;
|
* OSS 文件上传服务
|
||||||
import java.nio.file.Files;
|
* <p>
|
||||||
import java.nio.file.Path;
|
* 委托给 OssUtils 处理实际的上传逻辑。
|
||||||
import java.nio.file.Paths;
|
* 主要用于向后兼容服务端上传接口。
|
||||||
import java.time.LocalDate;
|
* </p>
|
||||||
import java.time.format.DateTimeFormatter;
|
*/
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class OssService {
|
public class OssService {
|
||||||
|
|
||||||
private final OssConfig ossConfig;
|
private final OssUtils ossUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件,优先使用腾讯云 COS,未配置时降级到本地存储
|
* 上传文件到 OSS(服务端上传,向后兼容)
|
||||||
*/
|
*/
|
||||||
public String uploadFile(MultipartFile file) {
|
public String uploadFile(MultipartFile file) {
|
||||||
String originalFilename = file.getOriginalFilename();
|
return ossUtils.uploadFile(file);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 工具类
|
||||||
|
* <p>
|
||||||
|
* 包含:前端直传签名生成、服务端上传、CORS 配置
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -33,12 +33,17 @@ mybatis-plus:
|
|||||||
configuration:
|
configuration:
|
||||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
|
||||||
oss:
|
# 阿里云 OSS 配置(开发环境)
|
||||||
secret-id: ${COS_SECRET_ID:}
|
aliyun:
|
||||||
secret-key: ${COS_SECRET_KEY:},
|
oss:
|
||||||
bucket: ${COS_BUCKET:}
|
endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com}
|
||||||
region: ${COS_REGION:ap-guangzhou}
|
access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||||
url-prefix: ${COS_URL_PREFIX:}
|
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:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@ -38,3 +38,15 @@ knife4j:
|
|||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.competition: info
|
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:*}
|
||||||
@ -33,12 +33,17 @@ mybatis-plus:
|
|||||||
configuration:
|
configuration:
|
||||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
|
||||||
oss:
|
# 阿里云 OSS 配置(开发环境)
|
||||||
secret-id: ${COS_SECRET_ID:}
|
aliyun:
|
||||||
secret-key: ${COS_SECRET_KEY:}
|
oss:
|
||||||
bucket: ${COS_BUCKET:}
|
endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com}
|
||||||
region: ${COS_REGION:ap-guangzhou}
|
access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||||
url-prefix: ${COS_URL_PREFIX:}
|
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:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
BIN
frontend/e2e/upload/fixtures/test-upload.png
Normal file
BIN
frontend/e2e/upload/fixtures/test-upload.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
130
frontend/e2e/upload/oss-upload.spec.ts
Normal file
130
frontend/e2e/upload/oss-upload.spec.ts
Normal file
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
90
frontend/playwright-report/index.html
Normal file
90
frontend/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@ -1,5 +1,10 @@
|
|||||||
|
import axios from "axios";
|
||||||
import request from "@/utils/request";
|
import request from "@/utils/request";
|
||||||
|
import { buildOssDirPath } from "@/utils/oss-env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传响应接口(保持向后兼容)
|
||||||
|
*/
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
url: string;
|
url: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@ -7,27 +12,121 @@ export interface UploadResponse {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadApi = {
|
/**
|
||||||
// 上传文件
|
* OSS 直传 Token 响应
|
||||||
upload: async (formData: FormData): Promise<UploadResponse> => {
|
*/
|
||||||
const response = await request.post<any, UploadResponse>(
|
interface OssToken {
|
||||||
"/upload",
|
accessid: string;
|
||||||
formData,
|
policy: string;
|
||||||
{
|
signature: string;
|
||||||
headers: {
|
dir: string;
|
||||||
"Content-Type": "multipart/form-data",
|
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<OssToken> {
|
||||||
|
const fullDir = buildOssDirPath(dir);
|
||||||
|
const response = await request.get<OssToken>("/upload/oss/token", {
|
||||||
|
params: { fileName, dir: fullDir },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接上传文件到阿里云 OSS
|
||||||
|
*/
|
||||||
|
async function uploadToOss(
|
||||||
|
file: File,
|
||||||
|
token: OssToken,
|
||||||
|
options?: UploadOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
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<UploadResponse> => {
|
||||||
|
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<UploadResponse> {
|
export async function uploadFile(
|
||||||
const formData = new FormData();
|
file: File,
|
||||||
formData.append("file", file);
|
dir?: string,
|
||||||
return uploadApi.upload(formData);
|
options?: UploadOptions,
|
||||||
|
): Promise<UploadResponse> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,7 @@ const editorConfig: Partial<IEditorConfig> = {
|
|||||||
uploadImage: {
|
uploadImage: {
|
||||||
async customUpload(file: File, insertFn: (url: string) => void) {
|
async customUpload(file: File, insertFn: (url: string) => void) {
|
||||||
try {
|
try {
|
||||||
const result: any = await uploadFile(file)
|
const result: any = await uploadFile(file, "editor/image")
|
||||||
const url = result.data?.url || result.url
|
const url = result.data?.url || result.url
|
||||||
if (url) {
|
if (url) {
|
||||||
insertFn(url)
|
insertFn(url)
|
||||||
|
|||||||
46
frontend/src/utils/oss-env.ts
Normal file
46
frontend/src/utils/oss-env.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* OSS 环境工具函数
|
||||||
|
*
|
||||||
|
* 根据当前运行环境自动添加 OSS 路径前缀(dev/test/prod),
|
||||||
|
* 不同环境的文件存储在不同目录下,互不干扰。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 环境前缀映射 */
|
||||||
|
const OSS_ENV_PREFIX_MAP: Record<string, string> = {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@ -277,7 +277,7 @@ const beforeFileUpload = (file: File) => {
|
|||||||
const handleCoverUpload = async (options: any) => {
|
const handleCoverUpload = async (options: any) => {
|
||||||
const { file, onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
try {
|
try {
|
||||||
const result: any = await uploadFile(file)
|
const result: any = await uploadFile(file, "contest/cover")
|
||||||
const url = result.data?.url || result.url
|
const url = result.data?.url || result.url
|
||||||
if (url) { form.coverUrl = url; onSuccess(); message.success("封面上传成功") }
|
if (url) { form.coverUrl = url; onSuccess(); message.success("封面上传成功") }
|
||||||
else throw new Error("无法获取图片地址")
|
else throw new Error("无法获取图片地址")
|
||||||
@ -287,7 +287,7 @@ const handleCoverUpload = async (options: any) => {
|
|||||||
const handlePosterUpload = async (options: any) => {
|
const handlePosterUpload = async (options: any) => {
|
||||||
const { file, onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
try {
|
try {
|
||||||
const result: any = await uploadFile(file)
|
const result: any = await uploadFile(file, "contest/poster")
|
||||||
const url = result.data?.url || result.url
|
const url = result.data?.url || result.url
|
||||||
if (url) { form.posterUrl = url; onSuccess(); message.success("海报上传成功") }
|
if (url) { form.posterUrl = url; onSuccess(); message.success("海报上传成功") }
|
||||||
else throw new Error("无法获取图片地址")
|
else throw new Error("无法获取图片地址")
|
||||||
@ -297,7 +297,7 @@ const handlePosterUpload = async (options: any) => {
|
|||||||
const handleAttachmentUpload = async (options: any) => {
|
const handleAttachmentUpload = async (options: any) => {
|
||||||
const { file, onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
try {
|
try {
|
||||||
const result: any = await uploadFile(file)
|
const result: any = await uploadFile(file, "contest/attachment")
|
||||||
const url = result.data?.url || result.url
|
const url = result.data?.url || result.url
|
||||||
if (url) {
|
if (url) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|||||||
@ -206,7 +206,7 @@ import type { FormInstance, UploadFile } from "ant-design-vue"
|
|||||||
import { worksApi, registrationsApi, type SubmitWorkForm } from "@/api/contests"
|
import { worksApi, registrationsApi, type SubmitWorkForm } from "@/api/contests"
|
||||||
import { getAI3DTasks, type AI3DTask } from "@/api/ai-3d"
|
import { getAI3DTasks, type AI3DTask } from "@/api/ai-3d"
|
||||||
import { useAuthStore } from "@/stores/auth"
|
import { useAuthStore } from "@/stores/auth"
|
||||||
import request from "@/utils/request"
|
import { uploadFile } from "@/api/upload"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -591,8 +591,8 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
// 上传3D文件
|
// 上传3D文件
|
||||||
try {
|
try {
|
||||||
const uploadedUrl = await uploadFile(form.localWorkFile)
|
const result = await uploadFile(form.localWorkFile, "contest/work")
|
||||||
modelFiles = [uploadedUrl]
|
modelFiles = [result.url]
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error("3D文件上传失败:" + (error?.message || "未知错误"))
|
message.error("3D文件上传失败:" + (error?.message || "未知错误"))
|
||||||
submitLoading.value = false
|
submitLoading.value = false
|
||||||
@ -601,7 +601,8 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
// 上传预览图
|
// 上传预览图
|
||||||
try {
|
try {
|
||||||
previewUrl = await uploadFile(form.localPreviewFile)
|
const result = await uploadFile(form.localPreviewFile, "contest/preview")
|
||||||
|
previewUrl = result.url
|
||||||
previewUrlsList = [previewUrl]
|
previewUrlsList = [previewUrl]
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error("预览图上传失败:" + (error?.message || "未知错误"))
|
message.error("预览图上传失败:" + (error?.message || "未知错误"))
|
||||||
@ -619,10 +620,10 @@ const handleSubmit = async () => {
|
|||||||
}> = []
|
}> = []
|
||||||
for (const file of form.attachmentFiles) {
|
for (const file of form.attachmentFiles) {
|
||||||
try {
|
try {
|
||||||
const url = await uploadFile(file)
|
const result = await uploadFile(file, "contest/attachment")
|
||||||
attachments.push({
|
attachments.push({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileUrl: url,
|
fileUrl: result.url,
|
||||||
fileType: file.type || undefined,
|
fileType: file.type || undefined,
|
||||||
size: String(file.size),
|
size: String(file.size),
|
||||||
})
|
})
|
||||||
@ -657,29 +658,6 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件上传函数
|
|
||||||
const uploadFile = async (file: File): Promise<string> => {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", file)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await request.post<any>("/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 = () => {
|
const handleCancel = () => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
|
|||||||
@ -438,7 +438,7 @@ const customUpload = async ({ file, onSuccess, onError }: any) => {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
const result = await uploadApi.upload(formData)
|
const result = await uploadApi.upload(formData, "homework/attachment")
|
||||||
file.url = result.url
|
file.url = result.url
|
||||||
onSuccess(result)
|
onSuccess(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -359,7 +359,7 @@ const customUpload = async ({ file, onSuccess, onError }: any) => {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
const result = await uploadApi.upload(formData)
|
const result = await uploadApi.upload(formData, "homework/attachment")
|
||||||
file.url = result.url
|
file.url = result.url
|
||||||
onSuccess(result)
|
onSuccess(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -387,7 +387,7 @@ const customUpload = async ({ file, onSuccess, onError }: any) => {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
const result = await uploadApi.upload(formData)
|
const result = await uploadApi.upload(formData, "homework/attachment")
|
||||||
file.url = result.url
|
file.url = result.url
|
||||||
onSuccess(result)
|
onSuccess(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
6
frontend/test-results/.last-run.json
Normal file
6
frontend/test-results/.last-run.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"e4788778f47ce754c470-78366da2488a38e4bf74"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
@ -526,7 +526,7 @@ public class LeaiDemoApplication {
|
|||||||
// 生成 HMAC 签名头
|
// 生成 HMAC 签名头
|
||||||
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
||||||
|
|
||||||
// 拼接 URL
|
// 拼接 URL(手动 URLEncode 参数值)
|
||||||
StringBuilder queryString = new StringBuilder();
|
StringBuilder queryString = new StringBuilder();
|
||||||
for (Map.Entry<String, String> e : queryParams.entrySet()) {
|
for (Map.Entry<String, String> e : queryParams.entrySet()) {
|
||||||
if (queryString.length() > 0) queryString.append("&");
|
if (queryString.length() > 0) queryString.append("&");
|
||||||
@ -536,7 +536,19 @@ public class LeaiDemoApplication {
|
|||||||
}
|
}
|
||||||
String url = LEAI_API_URL + "/api/v1/query/works?" + queryString.toString();
|
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);
|
String responseBody = httpGet(url, hmacHeaders);
|
||||||
Map<String, Object> responseJson = parseJsonObject(responseBody);
|
Map<String, Object> responseJson = parseJsonObject(responseBody);
|
||||||
|
|
||||||
@ -790,6 +802,11 @@ public class LeaiDemoApplication {
|
|||||||
/**
|
/**
|
||||||
* 调用 B2 GET /api/v1/query/work/{workId} 获取完整作品数据
|
* 调用 B2 GET /api/v1/query/work/{workId} 获取完整作品数据
|
||||||
* 使用 HMAC 认证
|
* 使用 HMAC 认证
|
||||||
|
*
|
||||||
|
* ★ 签名规则: queryParams 中放原始值(未编码),签名用原始值计算。
|
||||||
|
* URL 拼接时再做 URLEncode。
|
||||||
|
* 如果使用 RestTemplate,务必传 URI.create(url) 而非 String url,
|
||||||
|
* 否则会导致二次编码使签名校验失败(详见 B3 方法注释)。
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> fetchB2Detail(String workId) throws Exception {
|
private Map<String, Object> fetchB2Detail(String workId) throws Exception {
|
||||||
Map<String, String> queryParams = new TreeMap<>();
|
Map<String, String> queryParams = new TreeMap<>();
|
||||||
@ -797,6 +814,7 @@ public class LeaiDemoApplication {
|
|||||||
|
|
||||||
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
||||||
|
|
||||||
|
// URL 手动编码(HttpURLConnection 不会二次编码,RestTemplate 会!)
|
||||||
String url = LEAI_API_URL + "/api/v1/query/work/" + URLEncoder.encode(workId, "UTF-8")
|
String url = LEAI_API_URL + "/api/v1/query/work/" + URLEncoder.encode(workId, "UTF-8")
|
||||||
+ "?orgId=" + URLEncoder.encode(ORG_ID, "UTF-8");
|
+ "?orgId=" + URLEncoder.encode(ORG_ID, "UTF-8");
|
||||||
|
|
||||||
@ -848,6 +866,10 @@ public class LeaiDemoApplication {
|
|||||||
* 用 & 拼接成 "key1=val1&key2=val2&..." 格式
|
* 用 & 拼接成 "key1=val1&key2=val2&..." 格式
|
||||||
* 签名算法: HMAC-SHA256(签名字符串, appSecret), hex 编码
|
* 签名算法: HMAC-SHA256(签名字符串, appSecret), hex 编码
|
||||||
*
|
*
|
||||||
|
* ★ 重要: queryParams 中的 value 必须是原始值(未 URLEncode),不是编码后的值!
|
||||||
|
* 例: "2026-04-08T03:48:38Z"(正确) 而非 "2026-04-08T03%3A48%3A38Z"(错误)
|
||||||
|
* 签名用原始值计算,URL 拼接时再做 URLEncode,两步分开。
|
||||||
|
*
|
||||||
* 返回4个 Header:
|
* 返回4个 Header:
|
||||||
* X-App-Key: 机构ID
|
* X-App-Key: 机构ID
|
||||||
* X-Timestamp: 毫秒时间戳
|
* X-Timestamp: 毫秒时间戳
|
||||||
|
|||||||
448
oss-direct-upload-demo/README.md
Normal file
448
oss-direct-upload-demo/README.md
Normal file
@ -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
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun.oss</groupId>
|
||||||
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
|
<version>3.17.1</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
> 至少需要:`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
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { uploadFile } from "@/api/file";
|
||||||
|
|
||||||
|
async function handleUpload(file: File) {
|
||||||
|
const result = await uploadFile(file, "avatar", {
|
||||||
|
onProgress: (percent) => console.log(`上传进度: ${percent}%`),
|
||||||
|
});
|
||||||
|
console.log("上传成功:", result.filePath);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
> 参考 `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
|
||||||
|
```
|
||||||
58
oss-direct-upload-demo/backend/FileUploadController.java
Normal file
58
oss-direct-upload-demo/backend/FileUploadController.java
Normal file
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传控制器
|
||||||
|
* <p>
|
||||||
|
* 仅提供 OSS 直传 Token 获取接口。
|
||||||
|
* 实际文件上传由前端直接发送到阿里云 OSS,不经过后端。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h3>使用流程:</h3>
|
||||||
|
* <ol>
|
||||||
|
* <li>前端调用 GET /api/v1/files/oss/token 获取签名 Token</li>
|
||||||
|
* <li>前端使用 FormData 将文件 + Token 直接 POST 到阿里云 OSS</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/files")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "文件上传")
|
||||||
|
public class FileUploadController {
|
||||||
|
|
||||||
|
private final OssUtils ossUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取阿里云 OSS 直传 Token
|
||||||
|
* <p>
|
||||||
|
* 用于前端直传文件到 OSS,无需经过后端中转
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param fileName 原始文件名(如:图片.jpg)
|
||||||
|
* @param dir 目录前缀(可选,如:avatar, course/cover)
|
||||||
|
* @return OSS 直传 Token VO
|
||||||
|
*/
|
||||||
|
@GetMapping("/oss/token")
|
||||||
|
@Operation(summary = "获取阿里云 OSS 直传 Token")
|
||||||
|
public Map<String, Object> getOssToken(
|
||||||
|
@RequestParam("fileName") String fileName,
|
||||||
|
@RequestParam(value = "dir", required = false) String dir) {
|
||||||
|
|
||||||
|
OssTokenVo token = ossUtils.generatePostObjectToken(fileName, dir);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("message", "success");
|
||||||
|
result.put("data", token);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
oss-direct-upload-demo/backend/OssConfig.java
Normal file
79
oss-direct-upload-demo/backend/OssConfig.java
Normal file
@ -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 配置类
|
||||||
|
* <p>
|
||||||
|
* 从 application.yml 中读取 aliyun.oss 前缀的配置项
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
oss-direct-upload-demo/backend/OssCorsInitRunner.java
Normal file
40
oss-direct-upload-demo/backend/OssCorsInitRunner.java
Normal file
@ -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 初始化
|
||||||
|
* <p>
|
||||||
|
* 应用启动时自动配置 OSS 跨域规则,解决前端直传跨域问题。
|
||||||
|
* 需在配置中开启:aliyun.oss.cors-enabled=true
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h3>工作原理:</h3>
|
||||||
|
* <ol>
|
||||||
|
* <li>Spring Boot 启动完成后自动执行</li>
|
||||||
|
* <li>读取 aliyun.oss.cors-enabled 配置</li>
|
||||||
|
* <li>如果开启,调用 OSS API 设置 Bucket 的 CORS 规则</li>
|
||||||
|
* <li>如果失败(如权限不足),仅打印警告,不影响应用启动</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>也可以不使用此自动配置,改为在阿里云控制台手动设置 CORS。</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@Order(100) // 较晚执行,确保其他组件已就绪
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OssCorsInitRunner implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final OssUtils ossUtils;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
ossUtils.configureBucketCors();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
oss-direct-upload-demo/backend/OssTokenVo.java
Normal file
54
oss-direct-upload-demo/backend/OssTokenVo.java
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package com.example.oss.vo;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阿里云 OSS 直传 Token 响应 VO
|
||||||
|
* <p>
|
||||||
|
* 用于前端直传阿里云 OSS(PostObject 方式)
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
257
oss-direct-upload-demo/backend/OssUtils.java
Normal file
257
oss-direct-upload-demo/backend/OssUtils.java
Normal file
@ -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 工具类(前端直传专用)
|
||||||
|
* <p>
|
||||||
|
* 仅包含前端直传所需的核心方法:
|
||||||
|
* - generatePostObjectToken: 生成 PostObject 直传 Token
|
||||||
|
* - configureBucketCors: 配置 Bucket CORS 规则
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@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
|
||||||
|
* <p>
|
||||||
|
* 用于前端直传文件到 OSS,无需经过后端中转。
|
||||||
|
* 前端拿到此 Token 后,通过 FormData + POST 方式直接上传到 OSS。
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <h3>签名流程:</h3>
|
||||||
|
* <ol>
|
||||||
|
* <li>构建 Policy JSON(包含过期时间和 key 约束)</li>
|
||||||
|
* <li>Base64 编码 Policy</li>
|
||||||
|
* <li>使用 AccessKeySecret + HMAC-SHA1 对 Base64(Policy) 计算签名</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @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 规则,解决前端直传跨域问题
|
||||||
|
* <p>
|
||||||
|
* 需确保 OSS 账号有 oss:PutBucketCors 权限。
|
||||||
|
* 也可在阿里云控制台手动配置。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
oss-direct-upload-demo/backend/application-oss.yml
Normal file
32
oss-direct-upload-demo/backend/application-oss.yml
Normal file
@ -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}
|
||||||
38
oss-direct-upload-demo/backend/pom-oss.xml
Normal file
38
oss-direct-upload-demo/backend/pom-oss.xml
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 阿里云 OSS Maven 依赖片段 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- 将以下依赖复制到你的 pom.xml 的 <dependencies> 中 -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<!-- 阿里云 OSS SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun.oss</groupId>
|
||||||
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
|
<version>3.17.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok(OssConfig、OssTokenVo 等类使用) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Configuration Properties(OssConfig 使用) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Web(Controller 使用) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Swagger / SpringDoc(Controller 注解,可选) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.3.0</version>
|
||||||
|
</dependency>
|
||||||
210
oss-direct-upload-demo/frontend/UploadDemo.vue
Normal file
210
oss-direct-upload-demo/frontend/UploadDemo.vue
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
<!--
|
||||||
|
OSS 直传上传组件 Demo
|
||||||
|
|
||||||
|
最小可运行示例,展示如何使用 file.ts 和 env.ts 实现文件直传阿里云 OSS。
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
1. 将 file.ts 和 env.ts 复制到你的项目中
|
||||||
|
2. 安装 axios:npm install axios
|
||||||
|
3. 在页面中引入此组件即可使用
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="upload-demo">
|
||||||
|
<h2>阿里云 OSS 直传上传 Demo</h2>
|
||||||
|
|
||||||
|
<!-- 文件选择 -->
|
||||||
|
<div class="upload-area">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInput"
|
||||||
|
@change="handleFileChange"
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.mp4,.mp3"
|
||||||
|
/>
|
||||||
|
<button @click="handleUpload" :disabled="!selectedFile || uploading">
|
||||||
|
{{ uploading ? "上传中..." : "上传文件" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="uploading"
|
||||||
|
@click="handleCancel"
|
||||||
|
style="margin-left: 8px; color: red"
|
||||||
|
>
|
||||||
|
取消上传
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div v-if="uploading" class="progress-area">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span>{{ progress }}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传结果 -->
|
||||||
|
<div v-if="result" class="result-area">
|
||||||
|
<p>上传成功!</p>
|
||||||
|
<p>文件路径:{{ result.filePath }}</p>
|
||||||
|
<p>文件大小:{{ (result.fileSize / 1024).toFixed(1) }} KB</p>
|
||||||
|
<img
|
||||||
|
v-if="result.filePath && isImage(result.fileName)"
|
||||||
|
:src="result.filePath"
|
||||||
|
alt="预览"
|
||||||
|
style="max-width: 300px; margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<div v-if="error" class="error-area">
|
||||||
|
<p style="color: red">上传失败:{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { uploadFile } from "./file";
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement>();
|
||||||
|
const selectedFile = ref<File | null>(null);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const progress = ref(0);
|
||||||
|
const result = ref<{
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
} | null>(null);
|
||||||
|
const error = ref<string>("");
|
||||||
|
|
||||||
|
// 取消控制器
|
||||||
|
let abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
/** 判断是否为图片 */
|
||||||
|
function isImage(fileName: string): boolean {
|
||||||
|
return /\.(jpg|jpeg|png|gif|webp|bmp)$/i.test(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件选择事件 */
|
||||||
|
function handleFileChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
selectedFile.value = target.files[0];
|
||||||
|
error.value = "";
|
||||||
|
result.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开始上传 */
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!selectedFile.value) return;
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
progress.value = 0;
|
||||||
|
error.value = "";
|
||||||
|
result.value = null;
|
||||||
|
|
||||||
|
// 创建取消控制器
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadResult = await uploadFile(selectedFile.value, "demo", {
|
||||||
|
onProgress: (percent) => {
|
||||||
|
progress.value = percent;
|
||||||
|
},
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.value = {
|
||||||
|
filePath: uploadResult.filePath,
|
||||||
|
fileName: uploadResult.fileName,
|
||||||
|
fileSize: uploadResult.fileSize,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "CanceledError" || err.name === "AbortError") {
|
||||||
|
error.value = "上传已取消";
|
||||||
|
} else {
|
||||||
|
error.value = err.message || "未知错误";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消上传 */
|
||||||
|
function handleCancel() {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-demo {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #1890ff;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-area,
|
||||||
|
.error-area {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-area {
|
||||||
|
background: #f6ffed;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-area {
|
||||||
|
background: #fff2f0;
|
||||||
|
border: 1px solid #ffccc7;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: #1890ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
oss-direct-upload-demo/frontend/env.ts
Normal file
59
oss-direct-upload-demo/frontend/env.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 环境工具函数
|
||||||
|
*
|
||||||
|
* 提供环境相关的配置,用于自动为 OSS 路径添加环境前缀(dev/test/prod)。
|
||||||
|
* 这样不同环境的文件会存储在不同的目录下,互不干扰。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OSS 环境前缀映射
|
||||||
|
*
|
||||||
|
* 根据你的项目环境变量名修改此映射。
|
||||||
|
* Vite 默认使用 import.meta.env.MODE,值通常为 development / test / production。
|
||||||
|
*/
|
||||||
|
const OSS_ENV_PREFIX_MAP: Record<string, string> = {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
162
oss-direct-upload-demo/frontend/file.ts
Normal file
162
oss-direct-upload-demo/frontend/file.ts
Normal file
@ -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<OssToken> {
|
||||||
|
// 自动添加环境前缀(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<UploadResult> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user