Files
tiku-backend.net/Tiku.Infrastructure/Commerce/Foundation/CommerceAdministrationFoundation.Helpers.cs

509 lines
20 KiB
C#

using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Commerce;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using NotificationSeverity = Tiku.Domain.Commerce.NotificationSeverity;
namespace Tiku.Infrastructure.Commerce;
internal abstract partial class CommerceAdministrationServiceBase
{
protected async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
{
var access = await currentAccessContext.GetAsync(cancellationToken);
if (!access.IsCurrentTenantMember ||
access.UserId != actor.UserId ||
access.TenantId != actor.TenantId ||
!access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
}
protected async Task<CurrentDataScope> RequireDataScopeAsync(
CommerceAdminActor actor,
CancellationToken cancellationToken)
{
await AssertAdminAsync(actor, cancellationToken);
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
}
protected static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item)
{
return new TenantPaymentProviderItem(
item.Id,
item.Provider,
GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect",
item.DisplayName,
item.Status,
item.SecretRef,
item.Priority,
item.ConfigPublic,
item.CreatedAt,
item.UpdatedAt);
}
protected static TenantSecretItem ToSecretItem(TenantSecret item)
{
return new TenantSecretItem(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef,
item.Status.ToString(),
item.RotatedAt, item.ExpiresAt, item.UpdatedAt);
}
protected static CodeBatchItem ToCodeBatchItem(CodeBatch item)
{
return new CodeBatchItem(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType,
item.Channel,
item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt);
}
protected static ActivationCodeItem ToActivationCodeItem(ActivationCode item)
{
return new ActivationCodeItem(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy,
item.UsedAt, item.SaleType,
item.SoldTo, item.Remark, item.CreatedAt);
}
protected static CommerceOrderItem ToOrderItem(Order order)
{
return new CommerceOrderItem(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId,
order.ProductType,
order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider,
order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload);
}
protected static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo)
{
return new CommercePaymentItem(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method,
payment.Status.ToString(),
payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt,
JsonSerializer.SerializeToElement(new { }), payment.RawPayload);
}
protected static OrderStatus ParseOrderStatus(string? status)
{
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
}
protected static PaymentStatus ParsePaymentStatus(string? status)
{
return Enum.TryParse<PaymentStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
}
protected static PointActivityTaskStatus ParsePointTaskStatus(string? status)
{
return Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");
}
protected static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status)
{
return Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Point exchange item status is invalid.",
"invalid_point_exchange_item_status");
}
protected static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status)
{
return Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Point exchange order status is invalid.",
"invalid_point_exchange_order_status");
}
protected static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
{
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
}
protected static CommerceRefundStatus ParseRefundStatus(string? status)
{
return Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
}
protected static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status)
{
return Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Reconciliation batch status is invalid.",
"invalid_reconciliation_batch_status");
}
protected static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status)
{
return Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Reconciliation issue status is invalid.",
"invalid_reconciliation_issue_status");
}
protected static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status)
{
return Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
? parsed
: throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status");
}
protected async Task AssertOptionalReferenceAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
string code,
CancellationToken cancellationToken)
where TEntity : class
{
if (!id.HasValue) return;
var exists = await set.AnyAsync(
item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value,
cancellationToken);
if (!exists) throw new CommerceException("Referenced commerce entity was not found.", code);
}
protected async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
{
var order = await dbContext.Orders.SingleAsync(
item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
cancellationToken);
if (order.RefundedAmountCents < order.AmountCents)
{
order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents);
order.Status = order.RefundedAmountCents >= order.AmountCents
? OrderStatus.Refunded
: OrderStatus.PartiallyRefunded;
}
if (refund.PaymentId.HasValue)
{
var payment = await dbContext.Payments.SingleOrDefaultAsync(
item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value,
cancellationToken);
if (payment is not null)
{
payment.RefundedAmountCents =
Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
payment.Status = payment.RefundedAmountCents >= payment.AmountCents
? PaymentStatus.Refunded
: PaymentStatus.PartiallyRefunded;
}
}
}
protected static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
{
return from switch
{
CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected
or CommerceRefundStatus.Cancelled,
CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
_ => false
};
}
protected static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from,
CommerceAdjustmentVoucherStatus to)
{
return from switch
{
CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview
or CommerceAdjustmentVoucherStatus.Void,
CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved
or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void,
CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed,
_ => false
};
}
protected void AddRefundEvent(
CommerceRefundRequest refund,
CommerceRefundStatus? fromStatus,
CommerceRefundStatus toStatus,
string eventType,
Guid actorUserId,
object details)
{
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
{
TenantId = refund.TenantId,
RefundRequestId = refund.Id,
FromStatus = fromStatus,
ToStatus = toStatus,
EventType = eventType,
ActorUserId = actorUserId,
Details = JsonSerializer.SerializeToElement(details)
});
}
protected Task AddAuditAsync(
CommerceAdminActor actor,
string action,
string targetType,
Guid targetId,
object details,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
dbContext.AuditLogs.Add(new AuditLog
{
TenantId = actor.TenantId,
ActorUserId = actor.UserId,
Action = action,
TargetType = targetType,
TargetId = targetId.ToString(),
Details = JsonSerializer.SerializeToElement(details)
});
return Task.CompletedTask;
}
protected static string NormalizeEnum(string? value)
{
return string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
}
protected static string NormalizeProvider(string? provider)
{
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant()
.Replace("-", "_", StringComparison.Ordinal);
return normalized switch
{
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
"ali_pay" => PaymentProviders.Alipay,
"" => throw new CommerceException("Provider is required.", "provider_required"),
_ => normalized
};
}
protected static JsonElement WithPaymentMode(JsonElement element, string? mode)
{
var values = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
if (element.ValueKind == JsonValueKind.Object)
foreach (var property in element.EnumerateObject())
values[property.Name] = property.Value.Clone();
values["mode"] = JsonSerializer.SerializeToElement(
string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim());
return JsonSerializer.SerializeToElement(values);
}
protected static string? GetJsonString(JsonElement element, params string[] keys)
{
if (element.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
return value.GetString();
return null;
}
protected static JsonElement JsonObjectOrDefault(JsonElement element)
{
return element.ValueKind == JsonValueKind.Object
? element.Clone()
: JsonSerializer.SerializeToElement(new { });
}
protected static void AssertNoSecrets(JsonElement element, string path)
{
if (element.ValueKind != JsonValueKind.Object) return;
foreach (var property in element.EnumerateObject())
{
var key = property.Name.ToLowerInvariant();
if (key is "secretref" or "secret_ref") continue;
if (key.Contains("secret", StringComparison.Ordinal) ||
key.Contains("privatekey", StringComparison.Ordinal) ||
key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret")
throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret");
AssertNoSecrets(property.Value, $"{path}.{property.Name}");
}
}
protected static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows)
{
var normalizedProvider = NormalizeProvider(provider);
var parsedRows = EnumerateImportRows(rows).ToArray();
var paymentCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Payment);
var refundCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Refund);
var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched);
var amountCents = parsedRows.Sum(row => row.AmountCents);
var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents);
var sourceHash = Convert
.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}")))
.ToLowerInvariant();
return new ReconciliationImportPreview(
parsedRows.Length,
paymentCount,
refundCount,
invalidCount,
amountCents,
refundAmountCents,
sourceHash);
}
protected static IEnumerable<ReconciliationImportRow> EnumerateImportRows(JsonElement rows)
{
if (rows.ValueKind != JsonValueKind.Array)
throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows");
foreach (var row in rows.EnumerateArray())
{
if (row.ValueKind != JsonValueKind.Object)
{
yield return InvalidImportRow("row_not_object", row);
continue;
}
var transactionType = Enum.TryParse<ReconciliationTransactionType>(
NormalizeEnum(GetJsonString(row, "transactionType", "transaction_type") ?? "payment"),
true,
out var parsedTransactionType)
? parsedTransactionType
: ReconciliationTransactionType.Payment;
var amountCents = GetJsonInt(row, "amountCents", "amount_cents", "amount");
var refundAmountCents = GetJsonInt(row, "refundAmountCents", "refund_amount_cents", "refundAmount");
var providerTradeNo = GetJsonString(row, "providerTradeNo", "provider_trade_no", "tradeNo");
var providerRefundNo = GetJsonString(row, "providerRefundNo", "provider_refund_no");
var orderNo = GetJsonString(row, "orderNo", "order_no");
var refundNo = GetJsonString(row, "refundNo", "refund_no");
var issueCode = GetJsonString(row, "issueCode", "issue_code");
var matchStatus = Enum.TryParse<ReconciliationMatchStatus>(
NormalizeEnum(GetJsonString(row, "matchStatus", "match_status") ?? "matched"),
true,
out var parsedMatchStatus)
? parsedMatchStatus
: ReconciliationMatchStatus.AmountMismatch;
if (string.IsNullOrWhiteSpace(providerTradeNo) &&
string.IsNullOrWhiteSpace(providerRefundNo) &&
string.IsNullOrWhiteSpace(orderNo) &&
string.IsNullOrWhiteSpace(refundNo))
{
matchStatus = ReconciliationMatchStatus.MissingLocal;
issueCode ??= "missing_business_identifier";
}
yield return new ReconciliationImportRow(
transactionType,
providerTradeNo,
providerRefundNo,
orderNo,
refundNo,
Math.Max(0, amountCents),
Math.Max(0, refundAmountCents),
GetJsonString(row, "providerStatus", "provider_status"),
GetJsonString(row, "localStatus", "local_status"),
matchStatus,
issueCode,
row.Clone());
}
}
protected static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row)
{
return new ReconciliationImportRow(
ReconciliationTransactionType.Payment,
null,
null,
null,
null,
0,
0,
null,
null,
ReconciliationMatchStatus.AmountMismatch,
issueCode,
row.Clone());
}
protected static CommerceReconciliationItem CreateReconciliationItem(
Guid tenantId,
Guid batchId,
int rowNo,
string provider,
ReconciliationImportRow row)
{
return new CommerceReconciliationItem
{
TenantId = tenantId,
BatchId = batchId,
RowNo = rowNo,
Provider = provider,
TransactionType = row.TransactionType,
ProviderTradeNo = row.ProviderTradeNo,
ProviderRefundNo = row.ProviderRefundNo,
OrderNo = row.OrderNo,
RefundNo = row.RefundNo,
AmountCents = row.AmountCents,
RefundAmountCents = row.RefundAmountCents,
ProviderStatus = row.ProviderStatus,
LocalStatus = row.LocalStatus,
MatchStatus = row.MatchStatus,
Severity = row.MatchStatus == ReconciliationMatchStatus.Matched
? NotificationSeverity.Info
: NotificationSeverity.Warning,
IssueCode = row.IssueCode,
Details = row.Details
};
}
protected static int GetJsonInt(JsonElement element, params string[] keys)
{
foreach (var key in keys)
{
if (!element.TryGetProperty(key, out var value)) continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) return number;
if (value.ValueKind == JsonValueKind.String &&
int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed)) return parsed;
}
return 0;
}
protected static string GenerateActivationCode()
{
Span<byte> bytes = stackalloc byte[8];
RandomNumberGenerator.Fill(bytes);
return $"TKU{Convert.ToHexString(bytes)}";
}
protected static string FormatCny(int cents)
{
return (cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
}
protected sealed record ReconciliationImportRow(
ReconciliationTransactionType TransactionType,
string? ProviderTradeNo,
string? ProviderRefundNo,
string? OrderNo,
string? RefundNo,
int AmountCents,
int RefundAmountCents,
string? ProviderStatus,
string? LocalStatus,
ReconciliationMatchStatus MatchStatus,
string? IssueCode,
JsonElement Details);
}