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 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 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); } } }