169 lines
6.7 KiB
C#
169 lines
6.7 KiB
C#
/*
|
||
* @Author: Copilot
|
||
* @Description: OSS文件下载服务(Editor Only),从阿里云OSS下载Collect数据文件
|
||
* @Date: 2026年04月17日
|
||
* @Modify:
|
||
*/
|
||
|
||
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Threading.Tasks;
|
||
using System.Xml.Linq;
|
||
using UnityEngine;
|
||
|
||
|
||
namespace Logic.Editor
|
||
{
|
||
/// <summary>
|
||
/// Editor-only service for downloading files from Aliyun OSS.
|
||
/// Uses OSS REST API with V1 HMAC-SHA1 signature.
|
||
/// </summary>
|
||
public class OssDownloadService
|
||
{
|
||
private readonly string _accessKeyId;
|
||
private readonly string _accessKeySecret;
|
||
private readonly string _endpoint;
|
||
private readonly string _bucket;
|
||
|
||
private static readonly HttpClient SharedHttpClient = new() { Timeout = TimeSpan.FromMinutes(10) };
|
||
|
||
public OssDownloadService(string accessKeyId, string accessKeySecret,
|
||
string endpoint = "oss-cn-shanghai.aliyuncs.com", string bucket = "th1-oss")
|
||
{
|
||
_accessKeyId = accessKeyId?.Trim();
|
||
_accessKeySecret = accessKeySecret?.Trim();
|
||
_endpoint = endpoint?.Trim();
|
||
_bucket = bucket?.Trim();
|
||
|
||
// 打印密钥信息帮助排查问题
|
||
if (!string.IsNullOrEmpty(_accessKeySecret))
|
||
{
|
||
var secretLen = _accessKeySecret.Length;
|
||
var maskedSecret = secretLen > 4
|
||
? $"{_accessKeySecret.Substring(0, 2)}***{_accessKeySecret.Substring(secretLen - 2)}"
|
||
: "***";
|
||
Debug.Log($"[OSS配置检查] AccessKeyId: {_accessKeyId}, Secret长度: {secretLen}, Secret首尾: {maskedSecret}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 生成 OSS V1 签名
|
||
/// StringToSign = VERB + "\n" + "\n" + "\n" + Date + "\n" + CanonicalizedResource
|
||
/// </summary>
|
||
private string Sign(string verb, string date, string canonicalizedResource)
|
||
{
|
||
var stringToSign = $"{verb}\n\n\n{date}\n{canonicalizedResource}";
|
||
using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(_accessKeySecret));
|
||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
|
||
return Convert.ToBase64String(hash);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 列出指定前缀下的所有对象(自动分页)
|
||
/// </summary>
|
||
public async Task<List<string>> ListObjectsAsync(string prefix)
|
||
{
|
||
var keys = new List<string>();
|
||
string continuationToken = null;
|
||
bool isTruncated = true;
|
||
|
||
while (isTruncated)
|
||
{
|
||
var queryParams = $"list-type=2&prefix={Uri.EscapeDataString(prefix)}&max-keys=1000";
|
||
var canonicalizedResource = $"/{_bucket}/";
|
||
|
||
if (!string.IsNullOrEmpty(continuationToken))
|
||
{
|
||
var encodedToken = Uri.EscapeDataString(continuationToken);
|
||
queryParams += $"&continuation-token={encodedToken}";
|
||
canonicalizedResource += $"?continuation-token={encodedToken}";
|
||
}
|
||
|
||
var url = $"https://{_bucket}.{_endpoint}/?{queryParams}";
|
||
|
||
var now = DateTimeOffset.UtcNow;
|
||
var date = now.ToString("R");
|
||
var signature = Sign("GET", date, canonicalizedResource);
|
||
|
||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||
request.Headers.Date = now; // 强制使用完全一致的时间对象,避免 HttpClient 覆盖 Date
|
||
request.Headers.TryAddWithoutValidation("Authorization", $"OSS {_accessKeyId}:{signature}");
|
||
|
||
var response = await SharedHttpClient.SendAsync(request);
|
||
var xml = await response.Content.ReadAsStringAsync();
|
||
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
Debug.LogError($"<color=red>OSS ListObjects 失败: {response.StatusCode}</color>\n{xml}");
|
||
break;
|
||
}
|
||
|
||
var doc = XDocument.Parse(xml);
|
||
var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None;
|
||
|
||
foreach (var content in doc.Root!.Elements(ns + "Contents"))
|
||
{
|
||
var key = content.Element(ns + "Key")?.Value;
|
||
if (!string.IsNullOrEmpty(key) && !key.EndsWith("/"))
|
||
keys.Add(key);
|
||
}
|
||
|
||
isTruncated = bool.TryParse(doc.Root.Element(ns + "IsTruncated")?.Value, out var t) && t;
|
||
continuationToken = doc.Root.Element(ns + "NextContinuationToken")?.Value;
|
||
}
|
||
|
||
return keys;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下载单个 OSS 对象到本地文件
|
||
/// </summary>
|
||
public async Task<bool> DownloadObjectAsync(string objectKey, string localPath)
|
||
{
|
||
try
|
||
{
|
||
var now = DateTimeOffset.UtcNow;
|
||
var date = now.ToString("R");
|
||
var canonicalizedResource = $"/{_bucket}/{objectKey}";
|
||
var signature = Sign("GET", date, canonicalizedResource);
|
||
|
||
// 对 objectKey 中的每个路径段分别编码,保留 '/'
|
||
var encodedKey = string.Join("/",
|
||
objectKey.Split('/').Select(seg => Uri.EscapeDataString(seg)));
|
||
var url = $"https://{_bucket}.{_endpoint}/{encodedKey}";
|
||
|
||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||
request.Headers.Date = now; // 强制使用完全一致的时间对象
|
||
request.Headers.TryAddWithoutValidation("Authorization", $"OSS {_accessKeyId}:{signature}");
|
||
|
||
var response = await SharedHttpClient.SendAsync(request);
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
var error = await response.Content.ReadAsStringAsync();
|
||
Debug.LogError($"<color=red>OSS 下载失败 {objectKey}: {response.StatusCode}</color>\n{error}");
|
||
return false;
|
||
}
|
||
|
||
var dir = Path.GetDirectoryName(localPath);
|
||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||
Directory.CreateDirectory(dir);
|
||
|
||
var data = await response.Content.ReadAsByteArrayAsync();
|
||
File.WriteAllBytes(localPath, data);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"<color=red>OSS 下载异常 {objectKey}: {ex.Message}</color>");
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
}
|