Spring Cloud Alibaba/Spring Boot整合华为云存储实例(REST API方式)

一个小作业,初次尝试华为云存储,一点分享

原项目采用Spring Cloud Alibaba微服务技术、Spring Boot框架技术、VueJS前端框架开发技术,nacos注册中心,数据库为mysql

下面看一下没有运用云存储的原项目(可跳过):

原项目概览:

关键代码

application.yaml

java 复制代码
server:
  port: 8007


service:
  ipAddr: http://localhost
download:
  address: F:\file\


spring:
  application:
    name: uploadService8007
  servlet:
    multipart:
      max-file-size: 20MB
      max-request-size: 30MB
  resources:
    static-locations: file:f:/file/
  mvc:
    static-path-pattern: /file/**

  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
management:
  endpoints:
    web:
      exposure:
        include: '*'

其中定义了网关地址(在编码过程中为"http://localhost"地址),文件请求最大大小为20MB,以及最大请求大小为30MB,文件映射地址,此处将/file/**映射为本地F盘下的file文件(此处可自定义本地盘路径,自定义后将项目图片素材解压并存放至对应路径中

启动类

在src/main/Java目录下添加com.qst.upload包,并创建启动类port8007_upload,并添加启动方法

java 复制代码
@Import(value = WebMvcConfig.class)
@EnableDiscoveryClient

@SpringBootApplication
public class port8007_upload {
    public static void main(String[] args) {
        SpringApplication.run(port8007_upload.class,args);
    }

}

文件上传控制器

在com.qst.upload包下创建文件上传控制器UploadController,并添加图片上传,音频文件上传以及歌词文件上传接口方法

java 复制代码
@RestController

@RequestMapping("/upload")
public class UploadController {

    @Autowired
    UploadService uploadService;

    @PostMapping("upImage")
    public Mess upLoadImage(MultipartFile file){
        if(CheckUtil.isImage(file.getOriginalFilename())) {
            String url = uploadService.uploadImage(file);
            return Mess.success().mess("文件上传成功").data("url", url);
        }
        return Mess.fail().mess("文件格式错误");
    }

    @PostMapping("upLyric")
    public Mess upLoadLyric(MultipartFile file){
        if(CheckUtil.isLyric(file.getOriginalFilename())) {
            String url = uploadService.uploadLyric(file);
            return Mess.success().mess("文件上传成功").data("url", url);
        }
        return Mess.fail().mess("文件格式错误");
    }

    @PostMapping("upMusic")
    public Mess upLoadMusic(MultipartFile file){
        if (CheckUtil.isMusic(file.getOriginalFilename())){
            Integer length= MusicUtil.getDuration(file);
            String url=uploadService.uploadMusic(file);
            return Mess.success().mess("上传成功").data("url",url).data("timelength",length);
        }
        return Mess.fail().mess("文件格式错误");
    }


}

实现文件上传服务类

在com.qst.upload中创建文件上传服务类UploadService,并添加生成文件名方法,将文件保存到本地方法,上传歌曲方法,上传歌词方法以及上传图片方法

java 复制代码
@Service
public class UploadService {

    @Value("${download.address}")
    String address;

    @Value("${service.ipAddr}")
    String ip;


    public String uploadImage(MultipartFile file){
        return upLoadFile(file,"image");
    }
    public String uploadLyric(MultipartFile file){
        return upLoadFile(file,"lyric");
    }
    public String uploadMusic(MultipartFile file){
        return upLoadFile(file,"music");
    }




    public String upLoadFile(MultipartFile file,String type){
        OutputStream os = null;
        InputStream is = null;
        String newName=getNewName(file);
        try {
            is=file.getInputStream();
        } catch (IOException e) {
            e.printStackTrace();
        }
        try {
            byte[] bs=new byte[1024];
            int length;
            File tempFile = new File(address.concat(type));
            if (!tempFile.exists()){
                tempFile.mkdirs();
            }
            os = new FileOutputStream(tempFile.getPath().concat(File.separator).concat(newName));
            while ((length = is.read(bs)) != -1) {
                os.write(bs, 0, length);
            }

            return ip.concat("/file/").concat(type).concat("/").concat(newName);
        }catch (Exception e){
            System.out.println(e.getMessage());
        }finally {
            try {
                os.close();
                is.close();
            }catch (Exception e){

            }
        }
        return null;
    }

    public String getNewName(MultipartFile file){
        String uuid= UUID.randomUUID().toString();
        String filename=file.getOriginalFilename();
        String newName=uuid+filename.substring(filename.lastIndexOf("."));
        return newName;
    }

}

修改网关配置

网关微服务中添加上传微服务服务名。

添加路由转发,首先将上传请求转发到文件上传模块微服务中,由于配置了文件映射,所以将请求一级路径(文件访问请求)为"file"的请求转发到文件上传模块微服务中

java 复制代码
service:
  upload: uploadService8007
- id: upload
  uri: lb://${service.upload}
  predicates:
    - Path=/upload/**

- id: file
  uri: lb://${service.upload}
  predicates:
    - Path=/file/**

测试

图片上传接口

请求地址:"localhost/upload/upImage"

请求类型:Post

请求体:file:{上传文件}

响应:

复制代码
{
    "code": 20,
    "message": "文件上传成功",
    "success": true,
    "data": {
        "url": "http://localhost/file/image/a062734c-ce62-49c3-938c-2ecf9337a267.jpg"
    }
}

在postman中点击地址url可直接发送get请求,获取刚刚上传的文件

其他上传功能同理

把官方文档过一过脑子

官方开发流程概览:

序号 任务 说明
1 创建项目 项目是您在AppGallery Connect(以下简称AGC)资源的组织实体。当您需要使用云存储服务时,您需要先在AGC中创建您的项目。 说明 您可以通过创建不同的项目,实现分别在测试环境和开发环境使用云存储。
2 开通云存储服务 -
3 获取API授权 在向AGC服务端发起REST API请求前,您需要先获得AGC服务端的授权。
4 管理文件 通过调用云存储REST API,您可以进行上传文件下载文件删除文件文件元数据管理以及获取云端某个目录下的文件列表等操作。

其中第一步和第二步都是傻瓜式操作

其中第三步获取API授权

创建API客户端

API客户端是AGC用于管理用户访问AppGallery Connect API的身份凭据。在访问某个API前,必须创建有权访问该API的API客户端。

  1. 登录AppGallery Connect,点击"开发与服务"。
  2. 在项目列表中选择需要获取凭证的项目,在"项目设置"页面点击"Server SDK"页签。
  3. 点击认证凭据区域内"API客户端"旁的"创建"。
  4. 在弹出的提示框内点击"确认",完成认证凭据创建,点击"下载认证凭据"下载json文件,获取文件中的client_id和client_secret信息。

将下载后的认证凭据文件agc-apiclient-*.json放置到您的Server服务器中指定路径下,后续初始化SDK时将会使用到该文件

获取访问API的Token

创建完API客户端后需要到华为AGC平台进行鉴权,鉴权通过后将获得用于访问AppGallery Connect API的Access Token。用户凭借该Access Token即可访问REST API。您可以调用获取Token接口来获取Access Token。
功能介绍

在使用API客户端方式调用Connect API的接口前,需要通过华为开放平台进行鉴权,并获取认证通过后的Token。

接口原型

| 承载协议 | HTTPS POST |
| 接口方向 | 开发者服务器 -> 华为服务器 |
| 接口URL | https://{domain}/api/oauth2/v1/token * 中国站点的domain:connect-api.cloud.huawei.com * 德国站点的domain:connect-api-dre.cloud.huawei.com * 新加坡站点的domain:connect-api-dra.cloud.huawei.com * 俄罗斯站点的domain:connect-api-drru.cloud.huawei.com 注意 本接口使用的domain必须是项目设置的数据处理位置对应的domain,例如:项目设置数据处理位置为中国,那么本接口中的domain必须使用"connect-api.cloud.huawei.com"; |

数据格式 请求:Content-Type: application/json 响应:Content-Type: application/json

请求参数

请求参数以JSON格式传入,包含参数如下。

参数名称 必选(M)/可选(O) 数据类型 参数说明
grant_type M String(256) 固定传入"client_credentials"。
client_id M String(256) 客户端ID,即下载项目级凭证agc-apiclient-*.json文件中的client_id。
client_secret M String(2048) 客户端密钥,即下载项目级凭证agc-apiclient-*.json文件中的client_secret。

请求示例

hljs 复制代码
  1. POST /api/oauth2/v1/token
  2. Host: connect-api.cloud.huawei.com
  3. Content-Type: application/json
  4. {
  5. "grant_type":"client_credentials",
  6. "client_id":"26********20",
  7. "client_secret":"************************"
  8. }

响应参数

返回值为JSON格式的字符串,包含参数如下。

参数名称 必选(M)/可选(O) 数据类型 参数说明
access_token O String 认证Token,用于AppGallery Connect API接口调用。 此参数只在获取成功时返回。
expires_in O Long access_token的有效期,单位秒。您需要在过期时间到达时重新调用本接口获取新的access_token。 有效期为48小时,如果在有效期内再次调用接口获取access_token时,新老access_token都是有效的。 此参数只在获取成功时返回。
ret O String(100) 获取Token失败时的错误信息,包含错误码及描述信息的JSON字符串,格式为{"code":retcode , "msg": "description"},retcode为错误码,description为错误码描述信息。

postman在线调试接口(有助于理解截止目前的操作)

HMS Core | Postman API Network

下载文件接口:

功能介绍

此接口用于使用用户身份认证方式下载文件。

接口原型

| 承载协议 | HTTPS GET |
| 接口方向 | 开发者服务器->华为服务器 |
| 接口URL | https://{domain}/{bucket_name}/{object_name} 注意 * {bucket_name}为当前存储实例名称,{object_name}为需获取的文件名称。 * {domain}为接口域名。 * 中国站点的域名:ops-server-drcn.agcstorage.link/v0 * 德国站点的域名:ops-server-dre.agcstorage.link/v0 * 新加坡站点的域名:ops-server-dra.agcstorage.link/v0 * 俄罗斯站点的域名:ops-server-drru.agcstorage.link/v0 * 调用获取Token接口时使用的站点必须与本接口使用的站点保持一致。例如本接口使用的站点是德国,即接口域名为"ops-server-dre.agcstorage.link/v0",则调用获取Token接口也必须使用德国站点,其接口域名需为"connect-api-dre.cloud.huawei.com"。 |

数据格式 请求:Content-Type: application/json 响应:Content-Type: application/json

请求参数

参数 类型 必选(M)/可选(O) 说明
Authorization String M 认证信息,格式为"Authorization: Bearer ${access_token}"。access_token为获取Token中获取的access_token。
client_id String M 客户端ID,获取方法参考创建API客户端
productId String M 项目ID,查询方法可参见查询项目ID
Range String O HTTP标准协议头,用于指定第一个字节和最后一个字节的位置,告诉服务器想获取的文件范围。如未指定,则获取整个文件。 例如一个文件有900个字节,其范围为0-899,则: Range: bytes=500- 表示读取该文件的500-899字节。 Range: bytes=500-699 表示读取该文件的500-699字节。
If-Modified-Since String O 一个条件式请求首部。 服务器只在所请求的资源在给定的时间之后对内容进行过修改的情况下才会将资源返回,状态码为200 。如果请求的资源在给定的时间之后未经修改,则返回一个不带消息主体的304响应。 格式:<day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT 举例:Mon, 10 Apr 2023 07:28:00 GMT 说明 If-Modified-Since只可以用在GET或HEAD请求中。当与If-None-Match 一同出现时,If-Modified-Since会被忽略,除非服务器不支持If-None-Match。
If-None-Match String O 一个条件式请求首部。 对于GET和HEAD方法,当且仅当服务器上没有任何资源的ETag属性值与这个首部中列出的相匹配时,服务器端会才返回所请求的资源,响应码为200 ,否则返回304。其他方法暂不实现。 格式:If-None-Match: "<etag_value>" 不支持通配符*,不支持弱比较算法。
X-Agc-Trace-Id String O 单个文件请求的Traceid。可不填或者随机生成,用于日志打印跟踪。

请求示例

  1. GET /v0/testagc-02reg/test3.jpg
  2. client_id: 8490****0232064
  3. productId: 7364****4461998
  4. Content-Type: application/json
  5. X-Agc-Trace-Id: 123456789
  6. Authorization: Bearer ****
  7. User-Agent: PostmanRuntime/7.6.0
  8. Accept: */*
  9. Host: ops-server-drcn.agcstorage.link
  10. accept-encoding: gzip, deflate

响应参数

参数 类型 必选(M)/可选(O) 说明
status Integer M HTTP响应码,200表示成功。
X-Agc-Trace-Id String O 单个文件请求的Traceid,与请求参数X-Agc-Trace-Id保持一致。
Last-Modified String O 文件的最近修改时间,配合If-Modified-Since使用。 格式:<day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT 举例:Wed, 21 Oct 2015 07:28:00 GMT
Etag String O HTTP标准Header,配合If-None-Match使用。 格式:"<etag_value>" 举例:"33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control String O HTTP标准Header,通过指定指令来实现缓存机制。

代码实现/项目实践

开始之前需要导入需要的依赖(集成Java Server SDK)

云存储Java Server SDK发布在Maven仓库,您需要在pom.xml文件中添加Maven仓库地址和云存储服务SDK依赖。

  1. 在项目中找到pom.xml文件,并添加Maven仓库地址。

    1. <repositories>
    2. <repository>
    3. <id>sz-maven-public</id>
    4. <name>sz-maven-public</name>
    5. <url>https://developer.huawei.com/repo/\</url>
    6. </repository>
    7. </repositories>
  1. 添加云存储服务SDK的依赖。
    1. <dependency>
    2. <groupId>com.huawei.agconnect.server</groupId>
    3. <artifactId>agconnect-storage</artifactId>
    4. <version>1.2.0.101</version>
    5. </dependency>

上传控制器不用修改

修改配置文件

java 复制代码
server:
  port: 8007
service:
  ipAddr: http://localhost
download:
  address: D:\file\
spring:
  application:
    name: uploadService8007
  servlet:
    multipart:
      max-file-size: 20MB
      max-request-size: 30MB
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
management:
  endpoints:
    web:
      exposure:
        include: '*'

huawei:
  agc:
    credential-path: D:\Download\agc-apiclient-*** # 凭据文件路径
    bucket-name: ***  # 存储桶名称
    region: CN  # 区域标识(CN/DE/SG/RU)
    storage-url: https://ops-server-drcn.agcstorage.link/v0/  # 存储服务URL
    client-id: 17233***93184  # 客户端ID(从凭据文件获取)
    project-id: 46132***67964  # 项目ID(从凭据文件获取)

修改上传UploadService ,仍不是重点

java 复制代码
package com.qst.upload;

import com.qst.domain.entity.Log;
import com.qst.upload.huawei.HuaweiStorageUploadService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.UUID;

@Service
public class UploadService {
    @Value("${huawei.agc.bucket-name}")
    private String bucketName;

    @Autowired
    private HuaweiStorageUploadService huaweiStorageUploadService;

    public String uploadImage(MultipartFile file) {
        return upLoadFile(file, "image");
    }

    public String uploadLyric(MultipartFile file) {
        return upLoadFile(file, "lyric");
    }

    public String uploadMusic(MultipartFile file) {
        return upLoadFile(file, "music");
    }
    
    public String upLoadFile(MultipartFile file, String type) {
        try {
            // 生成新文件名
            String newFilename = getNewName(file);
            String objectPath = type + "/" + newFilename;

            // 上传到华为云存储
            huaweiStorageUploadService.uploadFile(file, objectPath);

            // 返回本地代理下载URL
            return "/upload/download/" + type + "/" + newFilename;
        } catch (Exception e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }
    public String getNewName(MultipartFile file) {
        String uuid = UUID.randomUUID().toString();
        String filename = file.getOriginalFilename();
        return uuid + filename.substring(filename.lastIndexOf("."));
    }

}

新增HuaweiStorageUploadService(重点)

java 复制代码
package com.qst.upload.huawei;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
//
///**
// * 华为云存储文件上传服务
// */
@Service
public class HuaweiStorageUploadService {
    private static final Logger log = LoggerFactory.getLogger(HuaweiStorageUploadService.class);

    @Value("${huawei.agc.region}")
    private String region;

    @Value("${huawei.agc.client-id}")
    private String clientId;

    @Value("${huawei.agc.project-id}")
    private String projectId;

    @Value("${huawei.agc.credential-path}")
    private String credentialPath;
    @Value("${huawei.agc.bucket-name}")
    private String bucketName;

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    private ObjectMapper objectMapper;
    /**
     * 上传文件到华为云存储
     */

    public String uploadFile(MultipartFile file, String objectPath) {
        try {
            // 构建上传URL
            String url = buildUploadUrl(objectPath);
            log.info("华为云文件上传URL: {}", url);

            // 准备请求头
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
            headers.set("client_id", clientId);
            headers.set("productId", projectId);
            headers.set("Authorization", "Bearer " + getAccessToken());
            headers.set("X-Agc-File-Size", String.valueOf(file.getSize()));
            headers.set("X-Agc-Trace-Id", UUID.randomUUID().toString());

            // 使用字节数组创建请求实体
            HttpEntity<byte[]> requestEntity = new HttpEntity<>(file.getBytes(), headers);

            // 执行PUT请求
            ResponseEntity<String> response = restTemplate.exchange(
                    url,
                    HttpMethod.PUT,
                    requestEntity,
                    String.class
            );

            // 处理响应
            if (response.getStatusCode() == HttpStatus.OK) {
                log.info("文件上传成功,华为云URL: {}", url);
                return url;
            } else {
                log.error("华为云文件上传失败,状态码: {}", response.getStatusCodeValue());
                throw new RuntimeException("文件上传失败");
            }
        } catch (Exception e) {
            log.error("华为云文件上传异常", e);
            throw new RuntimeException("文件上传失败", e);
        }
    }
    /**
     * 构建上传URL
     */
    private String buildUploadUrl(String objectPath) {
        String domain = getDomainByRegion(region);
        return "https://" + domain + "/" + bucketName + "/" + objectPath;
    }

    /**
     * 根据区域获取域名
     */
    private String getDomainByRegion(String region) {
        Map<String, String> domainMap = Map.of(
                "CN", "ops-server-drcn.agcstorage.link/v0",
                "DE", "ops-server-dre.agcstorage.link/v0",
                "SG", "ops-server-dra.agcstorage.link/v0",
                "RU", "ops-server-drru.agcstorage.link/v0"
        );
        String domain = domainMap.get(region.toUpperCase());
        if (domain == null) {
            throw new IllegalArgumentException("不支持的区域: " + region);
        }
        return domain;
    }

    /**
     * 获取访问令牌
     */
    private String getAccessToken() {
        try {
            String authUrl = getAuthUrlByRegion(region);
            log.info("获取华为云Token URL: {}", authUrl);

            // 读取凭据文件
            String content = new String(Files.readAllBytes(Paths.get(credentialPath)));
            Map<String, String> credential = objectMapper.readValue(content, new TypeReference<Map<String, String>>() {});

            // 准备请求头
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

            // 准备请求体
            Map<String, String> requestBody = new HashMap<>();
            requestBody.put("grant_type", "client_credentials");
            requestBody.put("client_id", credential.get("client_id"));
            requestBody.put("client_secret", credential.get("client_secret"));

            // 创建请求实体
            HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);

            // 发送请求(使用exchange方法)
            ResponseEntity<Map> response = restTemplate.exchange(
                    authUrl,
                    HttpMethod.POST,
                    requestEntity,
                    Map.class
            );

            // 处理响应
            if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
                String token = (String) response.getBody().get("access_token");
                log.info("成功获取华为云Token");
                return token;
            } else {
                log.error("获取华为云Token失败,状态码: {}", response.getStatusCodeValue());
                throw new RuntimeException("获取华为云Token失败");
            }
        } catch (Exception e) {
            log.error("获取华为云Token异常", e);
            throw new RuntimeException("获取Token失败", e);
        }
    }
    /**
     * 根据区域获取Token接口URL
     */
    private String getAuthUrlByRegion(String region) {
        Map<String, String> authUrlMap = Map.of(
                "CN", "https://connect-api.cloud.huawei.com/api/oauth2/v1/token",
                "DE", "https://connect-api-dre.cloud.huawei.com/api/oauth2/v1/token",
                "SG", "https://connect-api-dra.cloud.huawei.com/api/oauth2/v1/token",
                "RU", "https://connect-api-drru.cloud.huawei.com/api/oauth2/v1/token"
        );
        String url = authUrlMap.get(region.toUpperCase());
        if (url == null) {
            throw new IllegalArgumentException("不支持的区域: " + region);
        }
        return url;
    }
    
    private String buildDownloadUrl(String objectPath) {
        String domain = getDomainByRegion(region);
        return "https://" + domain + "/" + bucketName + "/" + objectPath;
    }
}

HuaweiStorageUploadService解析(重点)

  1. getDomainByRegion()根据不同的地区返回不同的华为服务器接口url。一般都是CN,也可不要这个方法
  2. buildUploadUrl()根据官方文档的指导,拼接接口URL:https://{domain}/{bucket_name}/{object_name}
  3. getAccessToken()获取访问API的Token

需要访问https://{domain}/api/oauth2/v1/token

传入合适的参数给该接口即可得到access_token,即认证Token,用于AppGallery Connect API接口调用

从下载的这个文件读取并解析client_secret和client_id等

java 复制代码
            // 准备请求头
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

            // 准备请求体
            Map<String, String> requestBody = new HashMap<>();
            requestBody.put("grant_type", "client_credentials");
            requestBody.put("client_id", credential.get("client_id"));
            requestBody.put("client_secret", credential.get("client_secret"));
            // 创建请求实体
            HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);

然后准备http请求,之后获取token

之后在uploadFile()方法

调用buildUploadUrl构建上传URL

准备请求头,其中调用刚刚说到的getAccessToken方法获取token

然后执行put请求并处理响应

新增HuaweiStorageDownloadService

java 复制代码
package com.qst.upload.huawei;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.Resource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;

@Service
public class HuaweiStorageDownloadService {

    private static final Logger log = LoggerFactory.getLogger(HuaweiStorageDownloadService.class);
    @Value("${huawei.agc.region}")
    private String region;
    @Value("${huawei.agc.client-id}")
    private String clientId;
    @Value("${huawei.agc.project-id}")
    private String projectId;
    @Value("${huawei.agc.bucket-name}")
    private String bucketName;
    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private ObjectMapper objectMapper;
    @Value("${huawei.agc.credential-path}")
    private String credentialPath;

    public ResponseEntity<StreamingResponseBody> downloadFile(
            String type,
            String filename,
            HttpHeaders originalHeaders) {

        try {
            // 1. 构建华为云下载URL
            String downloadUrl = buildDownloadUrl(type, filename);
            System.out.println("华为云下载URL: "+downloadUrl);
            log.info("代理下载华为云文件: {}", downloadUrl);

            // 2. 创建流式响应体
            StreamingResponseBody stream = outputStream -> {
                HttpURLConnection connection = null;
                InputStream input = null;
                try {
                    // 3. 创建HTTP连接
                    URL url = new URL(downloadUrl);
                    connection = (HttpURLConnection) url.openConnection();
                    connection.setRequestMethod("GET");

                    // 4. 设置请求头
                    connection.setRequestProperty("client_id", clientId);
                    connection.setRequestProperty("productId", projectId);
                    connection.setRequestProperty("Authorization", "Bearer " + getAccessToken());
                    connection.setRequestProperty("X-Agc-Trace-Id", UUID.randomUUID().toString());

                    // 5. 设置Range头部(如果存在)
                    if (originalHeaders.containsKey(HttpHeaders.RANGE)) {
                        String range = originalHeaders.getRange().get(0).toString();
                        connection.setRequestProperty("Range", range);
                    }

                    // 6. 连接并检查响应
                    connection.connect();
                    int status = connection.getResponseCode();

                    if (status >= 300) {
                        log.error("华为云下载失败: {}", status);
                        throw new RuntimeException("下载失败,状态码: " + status);
                    }

                    // 7. 获取输入流
                    input = connection.getInputStream();

                    // 8. 流式传输数据
                    byte[] buffer = new byte[8192];
                    int bytesRead;
                    while ((bytesRead = input.read(buffer)) != -1) {
                        try {
                            outputStream.write(buffer, 0, bytesRead);
                        } catch (IOException e) {
                            // 处理客户端断开连接
                            log.warn("客户端可能已断开连接: {}", e.getMessage());
                            break;
                        }
                    }
                    outputStream.flush();

                } catch (Exception e) {
                    log.error("下载处理失败", e);
                    throw new RuntimeException(e);
                } finally {
                    // 9. 确保关闭资源
                    if (input != null) {
                        try {
                            input.close();
                        } catch (IOException e) {
                            log.warn("关闭输入流失败", e);
                        }
                    }
                    if (connection != null) {
                        connection.disconnect();
                    }
                }
            };

            // 10. 设置响应头
            HttpHeaders responseHeaders = new HttpHeaders();
            responseHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
            responseHeaders.setContentDispositionFormData("attachment", filename);

            // 11. 返回响应
            return ResponseEntity.ok()
                    .headers(responseHeaders)
                    .body(stream);

        } catch (Exception e) {
            log.error("代理下载失败", e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
    // 构建下载URL
    private String buildDownloadUrl(String type, String filename) {
        String domain = getDomainByRegion(region);
        return "https://" + domain + "/" + bucketName + "/" + type + "/" + filename;
    }

    // 获取区域域名(与上传服务相同)
    private String getDomainByRegion(String region) {
        Map<String, String> domainMap = Map.of(
                "CN", "ops-server-drcn.agcstorage.link/v0",
                "DE", "ops-server-dre.agcstorage.link/v0",
                "SG", "ops-server-dra.agcstorage.link/v0",
                "RU", "ops-server-drru.agcstorage.link/v0"
        );
        String domain = domainMap.get(region.toUpperCase());
        if (domain == null) {
            throw new IllegalArgumentException("不支持的区域: " + region);
        }
        return domain;
    }

    // 获取访问令牌(与上传服务相同)
    private String getAccessToken() {
        try {
            String authUrl = getAuthUrlByRegion(region);
            log.info("获取华为云Token URL: {}", authUrl);

            // 读取凭据文件
            String content = new String(Files.readAllBytes(Paths.get(credentialPath)));
            Map<String, String> credential = objectMapper.readValue(content, new TypeReference<Map<String, String>>() {});

            // 准备请求头
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));

            // 准备请求体
            Map<String, String> requestBody = new HashMap<>();
            requestBody.put("grant_type", "client_credentials");
            requestBody.put("client_id", credential.get("client_id"));
            requestBody.put("client_secret", credential.get("client_secret"));

            // 创建请求实体
            HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(requestBody, headers);

            // 发送请求(使用exchange方法)
            ResponseEntity<Map> response = restTemplate.exchange(
                    authUrl,
                    HttpMethod.POST,
                    requestEntity,
                    Map.class
            );

            // 处理响应
            if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
                String token = (String) response.getBody().get("access_token");
                log.info("成功获取华为云Token");
                return token;
            } else {
                log.error("获取华为云Token失败,状态码: {}", response.getStatusCodeValue());
                throw new RuntimeException("获取华为云Token失败");
            }
        } catch (Exception e) {
            log.error("获取华为云Token异常", e);
            throw new RuntimeException("获取Token失败", e);
        }
    }
    private String getAuthUrlByRegion(String region) {
        Map<String, String> authUrlMap = Map.of(
                "CN", "https://connect-api.cloud.huawei.com/api/oauth2/v1/token",
                "DE", "https://connect-api-dre.cloud.huawei.com/api/oauth2/v1/token",
                "SG", "https://connect-api-dra.cloud.huawei.com/api/oauth2/v1/token",
                "RU", "https://connect-api-drru.cloud.huawei.com/api/oauth2/v1/token"
        );
        String url = authUrlMap.get(region.toUpperCase());
        if (url == null) {
            throw new IllegalArgumentException("不支持的区域: " + region);
        }
        return url;
    }
}

访问云存储与上传service类似,主要在于下载的代码,换了几个传输流,都有bug。

图文结合详解(重点)

最后这个流式传输才没有冲突,具体没有细究。


数据库存储的歌曲信息中包含了音频url,封面url等

url为/upload/download/music/9fb58a26-a351-4d78-9d72-66f255d80f74.mp3,是用来请求后端的

再由后端进行访问华为云

那为什么不把华为云接口直接存数据库,这样前端只要得到musicDetail就能直接去华为云拿数据了,不用经过后端来回倒腾了

因为华为云安全机制很严格,访问接口需要携带三个必须请求头:

|---------------|--------|---|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Authorization | String | M | 认证信息,格式为"Authorization: Bearer ${access_token}"。access_token为获取Token中获取的access_token。 |
| client_id | String | M | 客户端ID,获取方法参考创建API客户端。 |
| productId | String | M | 项目ID,查询方法可参见查询项目ID。 |

只能通过后端添加请求头来进行访问。

一个小问题

开发途中又发现拖动播放进度条的功能受限。原因是

音频文件不支持流式播放的进度跳转(seekable)。当通过代理服务提供音频流时,浏览器无法直接跳转到音频的中间位置,因为代理服务没有正确支持 HTTP Range 请求(即支持部分内容请求)。解决方案

修改 HuaweiStorageDownloadService,添加对 Range 请求的完整支持

java 复制代码
public ResponseEntity<StreamingResponseBody> downloadFile(
        String type,
        String filename,
        HttpHeaders originalHeaders) {
    try {
        // 1. 构建华为云下载URL
        String downloadUrl = buildDownloadUrl(type, filename);
        log.info("代理下载华为云文件: {}", downloadUrl);

        // 2. 创建HTTP连接
        URL url = new URL(downloadUrl);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");

        // 3. 设置认证头
        connection.setRequestProperty("client_id", clientId);
        connection.setRequestProperty("productId", projectId);
        connection.setRequestProperty("Authorization", "Bearer " + getAccessToken());
        connection.setRequestProperty("X-Agc-Trace-Id", UUID.randomUUID().toString());

        // 4. 处理Range请求
        if (originalHeaders.containsKey(HttpHeaders.RANGE)) {
            String rangeHeader = originalHeaders.getFirst(HttpHeaders.RANGE);
            connection.setRequestProperty("Range", rangeHeader);
        }

        // 5. 连接服务器
        connection.connect();

        // 6. 获取响应状态码
        int statusCode = connection.getResponseCode();

        // 7. 准备响应头
        HttpHeaders responseHeaders = new HttpHeaders();

        // 8. 添加内容类型(根据文件类型)
        String contentType = getContentType(filename);
        responseHeaders.setContentType(MediaType.parseMediaType(contentType));

        // 9. 添加支持Range的响应头
        responseHeaders.set("Accept-Ranges", "bytes");

        // 10. 处理部分内容响应(206)
        if (statusCode == HttpURLConnection.HTTP_PARTIAL) {
            String contentRange = connection.getHeaderField("Content-Range");
            responseHeaders.set("Content-Range", contentRange);
        }

        // 11. 添加内容长度(如果可用)
        String contentLength = connection.getHeaderField("Content-Length");
        if (contentLength != null) {
            responseHeaders.setContentLength(Long.parseLong(contentLength));
        }

        // 12. 创建流式响应体
        InputStream inputStream = connection.getInputStream();
        StreamingResponseBody stream = outputStream -> {
            try (inputStream) {
                byte[] buffer = new byte[8192];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                }
            } catch (IOException e) {
                log.warn("客户端可能已断开连接: {}", e.getMessage());
            } finally {
                connection.disconnect();
            }
        };

        // 13. 返回响应
        return ResponseEntity.status(statusCode)
                .headers(responseHeaders)
                .body(stream);

    } catch (Exception e) {
        log.error("代理下载失败", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}

    // 根据文件扩展名获取内容类型
    private String getContentType(String filename) {
        String extension = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
        switch (extension) {
            case "mp3": return "audio/mpeg";
            case "wav": return "audio/wav";
            case "ogg": return "audio/ogg";
            case "flac": return "audio/flac";
            case "m4a": return "audio/mp4";
            default: return "application/octet-stream";
        }
    }