173 lines
9.2 KiB
C#
173 lines
9.2 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace YMhut.Box.Core.Feedback;
|
|
|
|
public sealed class FeedbackSubmissionService(HttpClient? httpClient = null) : IFeedbackSubmissionService
|
|
{
|
|
public const string Endpoint = "https://mail-smtp.ymhut.cn/";
|
|
public const string ClientSignatureKey = "ymhut-box-feedback-client-v1";
|
|
private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
|
|
|
public async Task<FeedbackSubmissionResponse> SubmitAsync(
|
|
FeedbackRequest request,
|
|
FeedbackPackageResult package,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(package.PackagePath))
|
|
{
|
|
return new FeedbackSubmissionResponse(false, null, "PACKAGE_MISSING", "本地反馈包不存在。");
|
|
}
|
|
|
|
var encrypted = await FeedbackPackageCrypto.EncryptPackageAsync(package.PackagePath, cancellationToken: cancellationToken)
|
|
.ConfigureAwait(false);
|
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(global::System.Globalization.CultureInfo.InvariantCulture);
|
|
var nonce = Guid.NewGuid().ToString("N");
|
|
var feedbackCode = FeedbackCode.Normalize(request.FeedbackCode);
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
feedbackCode,
|
|
title = request.Title,
|
|
type = request.Type,
|
|
severity = request.Severity,
|
|
contact = request.Contact,
|
|
bodyLength = request.Body?.Length ?? 0,
|
|
packageEncrypted = true,
|
|
encryption = FeedbackPackageCrypto.MagicText,
|
|
packageBytes = encrypted.PackageBytes,
|
|
packageSha256 = encrypted.PackageSha256,
|
|
plainPackageBytes = package.PackageBytes,
|
|
plainPackageSha256 = package.PackageSha256,
|
|
createdAt = package.CreatedAt
|
|
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
|
var signature = Sign(timestamp, nonce, encrypted.PackageSha256, payload);
|
|
|
|
using var form = new MultipartFormDataContent();
|
|
form.Add(new StringContent(payload, Encoding.UTF8, "application/json"), "payload");
|
|
form.Add(new StringContent(timestamp, Encoding.UTF8), "timestamp");
|
|
form.Add(new StringContent(nonce, Encoding.UTF8), "nonce");
|
|
form.Add(new StringContent(encrypted.PackageSha256, Encoding.UTF8), "packageSha256");
|
|
form.Add(new StringContent(signature, Encoding.UTF8), "signature");
|
|
await using var stream = File.OpenRead(encrypted.PackagePath);
|
|
using var fileContent = new StreamContent(stream);
|
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
|
form.Add(fileContent, "package", Path.GetFileName(encrypted.PackagePath));
|
|
|
|
using var response = await _httpClient.PostAsync(Endpoint, form, cancellationToken).ConfigureAwait(false);
|
|
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
var parsed = ParseResponse(text);
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return parsed with
|
|
{
|
|
Ok = false,
|
|
ErrorCode = parsed.ErrorCode ?? $"HTTP_{(int)response.StatusCode}",
|
|
Message = parsed.Message ?? response.ReasonPhrase
|
|
};
|
|
}
|
|
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
|
{
|
|
return new FeedbackSubmissionResponse(false, null, exception is TaskCanceledException ? "TIMEOUT" : "NETWORK_ERROR", exception.Message);
|
|
}
|
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or CryptographicException)
|
|
{
|
|
return new FeedbackSubmissionResponse(false, null, "PACKAGE_ENCRYPT_FAILED", exception.Message);
|
|
}
|
|
}
|
|
|
|
public async Task<FeedbackStatusResponse> GetStatusAsync(
|
|
string feedbackCode,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var normalized = FeedbackCode.Normalize(feedbackCode);
|
|
if (!FeedbackCode.IsValid(normalized))
|
|
{
|
|
return new FeedbackStatusResponse(false, normalized, null, null, false, null, null, null, "INVALID_CODE", "Invalid feedback code.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var uri = $"{Endpoint}?api=status&code={Uri.EscapeDataString(normalized)}";
|
|
using var response = await _httpClient.GetAsync(uri, cancellationToken).ConfigureAwait(false);
|
|
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
var parsed = ParseStatusResponse(text);
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
return parsed;
|
|
}
|
|
|
|
return parsed with
|
|
{
|
|
Ok = false,
|
|
ErrorCode = parsed.ErrorCode ?? $"HTTP_{(int)response.StatusCode}",
|
|
Message = parsed.Message ?? response.ReasonPhrase
|
|
};
|
|
}
|
|
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
|
{
|
|
return new FeedbackStatusResponse(false, normalized, null, null, false, null, null, null, exception is TaskCanceledException ? "TIMEOUT" : "NETWORK_ERROR", exception.Message);
|
|
}
|
|
}
|
|
|
|
public static string Sign(string timestamp, string nonce, string packageSha256, string payload)
|
|
{
|
|
var material = $"{timestamp}\n{nonce}\n{packageSha256}\n{payload}";
|
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(ClientSignatureKey));
|
|
return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(material))).ToLowerInvariant();
|
|
}
|
|
|
|
private static FeedbackSubmissionResponse ParseResponse(string text)
|
|
{
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(text);
|
|
var root = document.RootElement;
|
|
var ok = root.TryGetProperty("ok", out var okElement) && okElement.ValueKind == JsonValueKind.True;
|
|
var code = root.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : null;
|
|
var error = root.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
|
|
var message = root.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null;
|
|
var duplicate = root.TryGetProperty("duplicate", out var duplicateElement) && duplicateElement.ValueKind == JsonValueKind.True;
|
|
return new FeedbackSubmissionResponse(ok, code, error, message, duplicate);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new FeedbackSubmissionResponse(false, null, "INVALID_RESPONSE", text);
|
|
}
|
|
}
|
|
|
|
private static FeedbackStatusResponse ParseStatusResponse(string text)
|
|
{
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(text);
|
|
var root = document.RootElement;
|
|
var ok = root.TryGetProperty("ok", out var okElement) && okElement.ValueKind == JsonValueKind.True;
|
|
var code = root.TryGetProperty("code", out var codeElement) ? codeElement.GetString() : null;
|
|
var status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
|
|
var statusLabel = root.TryGetProperty("statusLabel", out var statusLabelElement) ? statusLabelElement.GetString() : null;
|
|
var statusDetail = root.TryGetProperty("statusDetail", out var statusDetailElement) ? statusDetailElement.GetString() : null;
|
|
var category = root.TryGetProperty("category", out var categoryElement) ? categoryElement.GetString() : null;
|
|
var priority = root.TryGetProperty("priority", out var priorityElement) ? priorityElement.GetString() : null;
|
|
var hasReply = root.TryGetProperty("hasReply", out var hasReplyElement) && hasReplyElement.ValueKind == JsonValueKind.True;
|
|
var reply = root.TryGetProperty("reply", out var replyElement) ? replyElement.GetString() : null;
|
|
var receivedAt = root.TryGetProperty("receivedAt", out var receivedAtElement) ? receivedAtElement.GetString() : null;
|
|
var updatedAt = root.TryGetProperty("updatedAt", out var updatedAtElement) ? updatedAtElement.GetString() : null;
|
|
var mailSent = root.TryGetProperty("mailSent", out var mailSentElement) && mailSentElement.ValueKind == JsonValueKind.True;
|
|
var error = root.TryGetProperty("error", out var errorElement) ? errorElement.GetString() : null;
|
|
var message = root.TryGetProperty("message", out var messageElement) ? messageElement.GetString() : null;
|
|
return new FeedbackStatusResponse(ok, code, status, statusLabel, hasReply, reply, receivedAt, updatedAt, error, message, statusDetail, category, priority, mailSent);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new FeedbackStatusResponse(false, null, null, null, false, null, null, null, "INVALID_RESPONSE", text);
|
|
}
|
|
}
|
|
}
|