feat: complete phase six backoffice operations
This commit is contained in:
263
Tiku.Api/Contracts/PlatformAdminDtos.cs
Normal file
263
Tiku.Api/Contracts/PlatformAdminDtos.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class PlatformAdminQueryDto
|
||||
{
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
public string? Search { get; set; }
|
||||
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public PlatformAdminQuery ToQuery() => new(Status, Search, Limit);
|
||||
}
|
||||
|
||||
public sealed class CreatePlatformTenantDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(300)]
|
||||
public string? LegalName { get; set; }
|
||||
|
||||
public TenantStatus Status { get; set; } = TenantStatus.Active;
|
||||
public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial;
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreatePlatformTenantCommand ToCommand() => new(Slug, Name, LegalName, Status, BillingStatus, Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpdatePlatformTenantStatusDto
|
||||
{
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public TenantStatus Status { get; set; } = TenantStatus.Active;
|
||||
public BillingStatus BillingStatus { get; set; } = BillingStatus.Active;
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public UpdatePlatformTenantStatusCommand ToCommand() => new(TenantId, Status, BillingStatus, Reason);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformTenantBillingProfileDto
|
||||
{
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
[StringLength(300)]
|
||||
public string? BillingName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? TaxId { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
[StringLength(320)]
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? BillingAddress { get; set; }
|
||||
|
||||
[StringLength(300)]
|
||||
public string? InvoiceTitle { get; set; }
|
||||
|
||||
public TenantInvoiceTitleType? InvoiceType { get; set; }
|
||||
|
||||
[StringLength(300)]
|
||||
public string? BankName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string? BankAccountMasked { get; set; }
|
||||
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformTenantBillingProfileCommand ToCommand() => new(
|
||||
TenantId,
|
||||
BillingName,
|
||||
TaxId,
|
||||
ContactName,
|
||||
ContactPhone,
|
||||
ContactEmail,
|
||||
BillingAddress,
|
||||
InvoiceTitle,
|
||||
InvoiceType,
|
||||
BankName,
|
||||
BankAccountMasked,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformSubscriptionDto
|
||||
{
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string PlanCode { get; set; } = string.Empty;
|
||||
|
||||
public TenantSubscriptionStatus Status { get; set; } = TenantSubscriptionStatus.Active;
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
public string? BillingCycle { get; set; }
|
||||
|
||||
[Range(0, int.MaxValue)]
|
||||
public int AmountCents { get; set; }
|
||||
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformSubscriptionCommand ToCommand() => new(
|
||||
TenantId,
|
||||
PlanCode,
|
||||
Status,
|
||||
StartsAt,
|
||||
ExpiresAt,
|
||||
BillingCycle,
|
||||
AmountCents,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformStaffDto
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
[StringLength(320)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
public UserStatus Status { get; set; } = UserStatus.Active;
|
||||
public IReadOnlyCollection<Guid> RoleIds { get; set; } = [];
|
||||
|
||||
public UpsertPlatformStaffCommand ToCommand() => new(UserId, Email, Phone, Name, Status, RoleIds);
|
||||
}
|
||||
|
||||
public sealed class UpdatePlatformStaffStatusDto
|
||||
{
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public UserStatus Status { get; set; } = UserStatus.Active;
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public UpdatePlatformStaffStatusCommand ToCommand() => new(UserId, Status, Reason);
|
||||
}
|
||||
|
||||
public sealed class UpdatePlatformAuditAlertStatusDto
|
||||
{
|
||||
[Required]
|
||||
public Guid AlertId { get; set; }
|
||||
|
||||
public PlatformAuditAlertStatus Status { get; set; } = PlatformAuditAlertStatus.Acknowledged;
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? ResolutionNote { get; set; }
|
||||
|
||||
public UpdatePlatformAuditAlertStatusCommand ToCommand() => new(AlertId, Status, ResolutionNote);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformDunningChannelDto
|
||||
{
|
||||
public Guid? ChannelId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string ChannelCode { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public PlatformDunningProvider Provider { get; set; } = PlatformDunningProvider.Generic;
|
||||
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
public string WebhookUrl { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
public IReadOnlyCollection<string> ReminderTypes { get; set; } = ["overdue", "final_notice"];
|
||||
public IReadOnlyCollection<string> ReminderChannels { get; set; } = ["internal"];
|
||||
|
||||
[Range(1, 20)]
|
||||
public int MinReminderLevel { get; set; } = 1;
|
||||
|
||||
public IReadOnlyCollection<Guid> TenantIds { get; set; } = [];
|
||||
|
||||
[Range(1, 60)]
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformDunningChannelCommand ToCommand() => new(
|
||||
ChannelId,
|
||||
ChannelCode,
|
||||
Name,
|
||||
Description,
|
||||
Enabled,
|
||||
Provider,
|
||||
WebhookUrl,
|
||||
SecretRef,
|
||||
ReminderTypes,
|
||||
ReminderChannels,
|
||||
MinReminderLevel,
|
||||
TenantIds,
|
||||
TimeoutSeconds,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class DisablePlatformDunningChannelDto
|
||||
{
|
||||
[Required]
|
||||
public Guid ChannelId { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public DisablePlatformDunningChannelCommand ToCommand() => new(ChannelId, Reason);
|
||||
}
|
||||
|
||||
public sealed class RetryPlatformDunningEventDto
|
||||
{
|
||||
[Required]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public RetryPlatformDunningEventCommand ToCommand() => new(EventId, Reason);
|
||||
}
|
||||
@@ -252,6 +252,113 @@ public sealed class UpdateTenantAdminStudentStatusDto
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminStudentImportRowDto
|
||||
{
|
||||
public TenantAdminUserLookupDto User { get; set; } = new();
|
||||
public Guid? RegionId { get; set; }
|
||||
public Guid? ClassId { get; set; }
|
||||
public string? AvatarPreset { get; set; }
|
||||
public JsonElement RawProfile { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public TenantAdminStudentImportRowCommand ToCommand()
|
||||
{
|
||||
return new TenantAdminStudentImportRowCommand(User.ToCommand(), RegionId, ClassId, AvatarPreset, RawProfile);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminStudentImportDto
|
||||
{
|
||||
[Required]
|
||||
public IReadOnlyCollection<TenantAdminStudentImportRowDto> Rows { get; set; } = [];
|
||||
|
||||
public TenantAdminStudentImportCommand ToCommand()
|
||||
{
|
||||
return new TenantAdminStudentImportCommand(Rows.Select(row => row.ToCommand()).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminBulkAssignClassDto
|
||||
{
|
||||
[Required]
|
||||
public Guid ClassId { get; set; }
|
||||
|
||||
[Required]
|
||||
public IReadOnlyCollection<Guid> UserIds { get; set; } = [];
|
||||
|
||||
public TenantAdminBulkAssignClassCommand ToCommand()
|
||||
{
|
||||
return new TenantAdminBulkAssignClassCommand(ClassId, UserIds);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantAdminBulkStatusDto
|
||||
{
|
||||
[Required]
|
||||
public IReadOnlyCollection<Guid> UserIds { get; set; } = [];
|
||||
|
||||
public string? Status { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public TenantAdminBulkStatusCommand ToCommand()
|
||||
{
|
||||
return new TenantAdminBulkStatusCommand(UserIds, Status, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantSupervisionRuleDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[Range(0, 3650)]
|
||||
public int? DaysWithoutCheckIn { get; set; }
|
||||
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? MaxQuestionsAnsweredToday { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? FollowupType { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? Priority { get; set; }
|
||||
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantSupervisionRuleCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantSupervisionRuleCommand(
|
||||
Code,
|
||||
Title,
|
||||
Enabled,
|
||||
DaysWithoutCheckIn,
|
||||
MaxQuestionsAnsweredToday,
|
||||
FollowupType,
|
||||
Priority,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantSupervisionGenerateDto
|
||||
{
|
||||
public IReadOnlyCollection<Guid>? UserIds { get; set; }
|
||||
public Guid? AssignedToUserId { get; set; }
|
||||
public DateTimeOffset? DueAt { get; set; }
|
||||
|
||||
public TenantSupervisionGenerateCommand ToCommand()
|
||||
{
|
||||
return new TenantSupervisionGenerateCommand(UserIds, AssignedToUserId, DueAt);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantAdminStudentNoteDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
@@ -334,6 +334,111 @@ public sealed class UpdateReconciliationIssueDto
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PreviewReconciliationImportDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
public DateOnly BillDate { get; set; }
|
||||
public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined;
|
||||
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
|
||||
public JsonElement Rows { get; set; } = JsonDefaults.Array();
|
||||
|
||||
public PreviewReconciliationImportCommand ToPreviewCommand() => new(Provider, BillDate, BillType, SourceName, Rows);
|
||||
|
||||
public ImportReconciliationCommand ToImportCommand() => new(Provider, BillDate, BillType, SourceName, Rows);
|
||||
}
|
||||
|
||||
public sealed class CreateAdjustmentVoucherDto
|
||||
{
|
||||
public Guid? IssueId { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
public Guid? ItemId { get; set; }
|
||||
public Guid? OrderId { get; set; }
|
||||
public Guid? PaymentId { get; set; }
|
||||
public Guid? RefundRequestId { get; set; }
|
||||
public CommerceAdjustmentDirection Direction { get; set; } = CommerceAdjustmentDirection.IncreaseRevenue;
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int AmountCents { get; set; }
|
||||
|
||||
[StringLength(10)]
|
||||
public string? Currency { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(1000)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? ProofAssetKey { get; set; }
|
||||
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreateAdjustmentVoucherCommand ToCommand() => new(
|
||||
IssueId,
|
||||
BatchId,
|
||||
ItemId,
|
||||
OrderId,
|
||||
PaymentId,
|
||||
RefundRequestId,
|
||||
Direction,
|
||||
AmountCents,
|
||||
Currency,
|
||||
Reason,
|
||||
ProofAssetKey,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpdateAdjustmentVoucherStatusDto
|
||||
{
|
||||
[Required]
|
||||
public Guid VoucherId { get; set; }
|
||||
|
||||
public CommerceAdjustmentVoucherStatus Status { get; set; } = CommerceAdjustmentVoucherStatus.PendingReview;
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
public UpdateAdjustmentVoucherStatusCommand ToCommand() => new(VoucherId, Status, Note);
|
||||
}
|
||||
|
||||
public sealed class RequestProviderBillJobDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
public DateOnly BillDate { get; set; }
|
||||
public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined;
|
||||
public DateTimeOffset? RunAfter { get; set; }
|
||||
|
||||
public RequestProviderBillJobCommand ToCommand() => new(Provider, BillDate, BillType, RunAfter);
|
||||
}
|
||||
|
||||
public sealed class RefundNotificationDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefundNo { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(200)]
|
||||
public string? ProviderRefundNo { get; set; }
|
||||
|
||||
public CommerceRefundStatus Status { get; set; } = CommerceRefundStatus.Succeeded;
|
||||
|
||||
[StringLength(200)]
|
||||
public string? EventId { get; set; }
|
||||
|
||||
public JsonElement Payload { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public RefundNotificationCommand ToCommand(string provider) => new(provider, RefundNo, ProviderRefundNo, Status, EventId, Payload);
|
||||
}
|
||||
|
||||
public sealed class UpdatePointExchangeOrderStatusDto
|
||||
{
|
||||
[Required]
|
||||
|
||||
@@ -7,6 +7,7 @@ using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -16,6 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/commerce")]
|
||||
public sealed class CommerceController(
|
||||
ICommerceService commerceService,
|
||||
ICommerceAdminService commerceAdminService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant,
|
||||
ITenantContextInitializer tenantInitializer,
|
||||
@@ -151,6 +153,36 @@ public sealed class CommerceController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refunds/notify/wechat_pay")]
|
||||
[EndpointSummary("微信退款结果回调")]
|
||||
[ProducesResponseType<CommerceRefundRequest>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceRefundRequest>> WechatRefundNotify(
|
||||
[FromQuery] string? tenantCode,
|
||||
RefundNotificationDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.ProcessRefundNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
request.ToCommand(PaymentProviders.WechatPay),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refunds/notify/alipay")]
|
||||
[EndpointSummary("支付宝退款结果回调")]
|
||||
[ProducesResponseType<CommerceRefundRequest>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceRefundRequest>> AlipayRefundNotify(
|
||||
[FromQuery] string? tenantCode,
|
||||
RefundNotificationDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.ProcessRefundNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
request.ToCommand(PaymentProviders.Alipay),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveNotificationTenantAsync(
|
||||
string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
266
Tiku.Api/Controllers/PlatformAdminController.cs
Normal file
266
Tiku.Api/Controllers/PlatformAdminController.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = BackendPermissions.PlatformDashboardView)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin")]
|
||||
public sealed class PlatformAdminController(
|
||||
IPlatformAdminService platformAdminService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
[EndpointSummary("查询平台经营概览")]
|
||||
[ProducesResponseType<PlatformOverview>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformOverview>> Overview(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetOverviewAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("tenants")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询平台租户列表")]
|
||||
[ProducesResponseType<PlatformTenantList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantList>> Tenants(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetTenantsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("tenants")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("创建平台租户")]
|
||||
[ProducesResponseType<PlatformTenantItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantItem>> CreateTenant(
|
||||
CreatePlatformTenantDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.CreateTenantAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("tenants/detail")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询平台租户详情")]
|
||||
[ProducesResponseType<PlatformTenantDetail>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantDetail>> TenantDetail(
|
||||
[FromQuery] Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetTenantDetailAsync(ResolveActor(), tenantId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPatch("tenants/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("更新租户业务与账务状态")]
|
||||
[ProducesResponseType<PlatformTenantItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantItem>> TenantStatus(
|
||||
UpdatePlatformTenantStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpdateTenantStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("tenants/billing-profile")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("保存租户账务与开票资料")]
|
||||
[ProducesResponseType<TenantBillingProfileItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantBillingProfileItem>> BillingProfile(
|
||||
UpsertPlatformTenantBillingProfileDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertTenantBillingProfileAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("plans")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询平台 SaaS 套餐")]
|
||||
[ProducesResponseType<PlatformPlanList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformPlanList>> Plans(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetPlansAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("创建或调整租户订阅")]
|
||||
[ProducesResponseType<PlatformTenantSubscriptionItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantSubscriptionItem>> UpsertSubscription(
|
||||
UpsertPlatformSubscriptionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertSubscriptionAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("domains")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询租户域名状态")]
|
||||
[ProducesResponseType<PlatformDomainList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDomainList>> Domains(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDomainsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("domains/{domainId:guid}/recheck")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("重新触发租户域名 DNS/TLS 验证")]
|
||||
[ProducesResponseType<PlatformDomainRecheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDomainRecheckResult>> RecheckDomain(
|
||||
Guid domainId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.RecheckDomainAsync(ResolveActor(), domainId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("staff")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("查询平台员工列表")]
|
||||
[ProducesResponseType<PlatformStaffList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffList>> Staff(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetStaffAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("staff")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("创建或更新平台员工")]
|
||||
[ProducesResponseType<PlatformStaffItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffItem>> UpsertStaff(
|
||||
UpsertPlatformStaffDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertStaffAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPatch("staff/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("启用或禁用平台员工")]
|
||||
[ProducesResponseType<PlatformStaffItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffItem>> StaffStatus(
|
||||
UpdatePlatformStaffStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计日志")]
|
||||
[ProducesResponseType<PlatformAuditLogList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditLogList>> AuditLogs(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetAuditLogsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("audit-alerts")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计告警")]
|
||||
[ProducesResponseType<PlatformAuditAlertList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditAlertList>> AuditAlerts(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetAuditAlertsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("audit-alerts/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("更新平台审计告警状态")]
|
||||
[ProducesResponseType<PlatformAuditAlert>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditAlert>> AuditAlertStatus(
|
||||
UpdatePlatformAuditAlertStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpdateAuditAlertStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("dunning-notification-channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformDunningChannelList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningChannelList>> DunningChannels(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDunningChannelsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("dunning-notification-channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("创建或更新平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningChannelItem>> UpsertDunningChannel(
|
||||
UpsertPlatformDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("dunning-notification-channels/disable")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("禁用平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningChannelItem>> DisableDunningChannel(
|
||||
DisablePlatformDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.DisableDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("dunning-notification-events")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件")]
|
||||
[ProducesResponseType<PlatformDunningEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningEventList>> DunningEvents(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDunningEventsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("dunning-notification-events/detail")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件详情")]
|
||||
[ProducesResponseType<PlatformDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningEventItem>> DunningEventDetail(
|
||||
[FromQuery] Guid eventId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDunningEventDetailAsync(ResolveActor(), eventId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("dunning-notification-events/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("重新标记平台催缴通知事件待发送")]
|
||||
[ProducesResponseType<PlatformDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningEventItem>> RetryDunningEvent(
|
||||
RetryPlatformDunningEventDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.RetryDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
private PlatformAdminActor ResolveActor()
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
{
|
||||
throw new PlatformAdminException("Platform admin actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
|
||||
return new PlatformAdminActor(userId);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,15 @@ public sealed class TenantAdminDirectController(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
[Authorize(Policy = BackendPermissions.TenantDashboardView)]
|
||||
[EndpointSummary("查询租户后台运营概览")]
|
||||
[ProducesResponseType<TenantAdminOverviewItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminOverviewItem>> GetOverview(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetOverviewAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("classes")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询租户班级")]
|
||||
@@ -116,6 +125,117 @@ public sealed class TenantAdminDirectController(
|
||||
return Ok(await tenantAdminService.UpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("students/import/preview")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("预览学生批量导入")]
|
||||
[ProducesResponseType<TenantAdminStudentImportPreview>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminStudentImportPreview>> PreviewStudentImport(
|
||||
TenantAdminStudentImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.PreviewStudentImportAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("students/import")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("提交学生批量导入")]
|
||||
[ProducesResponseType<TenantAdminStudentImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminStudentImportResult>> ImportStudents(
|
||||
TenantAdminStudentImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.ImportStudentsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("students/bulk-assign-class")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("批量分班")]
|
||||
[ProducesResponseType<TenantAdminBulkOperationResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminBulkOperationResult>> BulkAssignClass(
|
||||
TenantAdminBulkAssignClassDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.BulkAssignClassAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("students/bulk-status")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("批量调整学生状态")]
|
||||
[ProducesResponseType<TenantAdminBulkOperationResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdminBulkOperationResult>> BulkStudentStatus(
|
||||
TenantAdminBulkStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.BulkUpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("students/supervision/rules")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询学习督导规则")]
|
||||
[ProducesResponseType<CatalogList<TenantSupervisionRuleItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<TenantSupervisionRuleItem>>> SupervisionRules(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetSupervisionRulesAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("students/supervision/rules")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("新增或更新学习督导规则")]
|
||||
[ProducesResponseType<ContentManagementResult<TenantSupervisionRuleItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<TenantSupervisionRuleItem>>> UpsertSupervisionRule(
|
||||
UpsertTenantSupervisionRuleDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.UpsertSupervisionRuleAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("students/supervision/preview")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("预览学习风险学生")]
|
||||
[ProducesResponseType<TenantSupervisionPreview>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantSupervisionPreview>> PreviewSupervision(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.PreviewSupervisionAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("students/supervision/generate")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("批量生成学习督导跟进任务")]
|
||||
[ProducesResponseType<TenantSupervisionGenerateResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantSupervisionGenerateResult>> GenerateSupervision(
|
||||
TenantSupervisionGenerateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GenerateSupervisionFollowupsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("student-followups/report")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询学生跟进统计报表")]
|
||||
[ProducesResponseType<TenantFollowupReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantFollowupReport>> FollowupReport(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetFollowupReportAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("feedbacks/report")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询反馈处理统计报表")]
|
||||
[ProducesResponseType<TenantFeedbackReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantFeedbackReport>> FeedbackReport(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetFeedbackReportAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("points/risk-report")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询积分风险统计报表")]
|
||||
[ProducesResponseType<TenantPointRiskReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantPointRiskReport>> PointRiskReport(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await tenantAdminService.GetPointRiskReportAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("student-notes")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[EndpointSummary("查询学生备注")]
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
@@ -374,6 +375,34 @@ public sealed class TenantCommerceController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("reconciliation/items")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询对账明细")]
|
||||
[ProducesResponseType<TenantReconciliationItemList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantReconciliationItemList>> ReconciliationItems(
|
||||
[FromQuery] Guid batchId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetReconciliationItemsAsync(
|
||||
ResolveActor(),
|
||||
batchId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("reconciliation/issues/events")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询对账工单事件")]
|
||||
[ProducesResponseType<TenantReconciliationIssueEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantReconciliationIssueEventList>> ReconciliationIssueEvents(
|
||||
[FromQuery] Guid issueId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetReconciliationIssueEventsAsync(
|
||||
ResolveActor(),
|
||||
issueId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("reconciliation/issues/status")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("更新对账异常状态")]
|
||||
@@ -388,6 +417,154 @@ public sealed class TenantCommerceController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("adjustment-vouchers")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账凭证")]
|
||||
[ProducesResponseType<TenantAdjustmentVoucherList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdjustmentVoucherList>> AdjustmentVouchers(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetAdjustmentVouchersAsync(
|
||||
ResolveActor(),
|
||||
ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("adjustment-vouchers/detail")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账凭证详情")]
|
||||
[ProducesResponseType<CommerceAdjustmentVoucher>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceAdjustmentVoucher>> AdjustmentVoucherDetail(
|
||||
[FromQuery] Guid voucherId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetAdjustmentVoucherAsync(
|
||||
ResolveActor(),
|
||||
voucherId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("adjustment-vouchers")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("创建调账凭证")]
|
||||
[ProducesResponseType<CommerceAdjustmentVoucher>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceAdjustmentVoucher>> CreateAdjustmentVoucher(
|
||||
CreateAdjustmentVoucherDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.CreateAdjustmentVoucherAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("adjustment-vouchers/status")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("审核或关闭调账凭证")]
|
||||
[ProducesResponseType<CommerceAdjustmentVoucher>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceAdjustmentVoucher>> UpdateAdjustmentVoucherStatus(
|
||||
UpdateAdjustmentVoucherStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.UpdateAdjustmentVoucherStatusAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("adjustment-vouchers/events")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账凭证事件")]
|
||||
[ProducesResponseType<TenantAdjustmentVoucherEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdjustmentVoucherEventList>> AdjustmentVoucherEvents(
|
||||
[FromQuery] Guid voucherId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetAdjustmentVoucherEventsAsync(
|
||||
ResolveActor(),
|
||||
voucherId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("adjustment-vouchers/report")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账统计报告")]
|
||||
[ProducesResponseType<TenantAdjustmentReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdjustmentReport>> AdjustmentVoucherReport(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetAdjustmentReportAsync(
|
||||
ResolveActor(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("reconciliation/anomalies")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询对账异常汇总")]
|
||||
[ProducesResponseType<TenantCommerceAnomalySummary>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCommerceAnomalySummary>> ReconciliationAnomalies(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetAnomalySummaryAsync(
|
||||
ResolveActor(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("reconciliation/preview")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("预览支付渠道账单对账")]
|
||||
[ProducesResponseType<ReconciliationImportPreview>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ReconciliationImportPreview>> PreviewReconciliation(
|
||||
PreviewReconciliationImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.PreviewReconciliationImportAsync(
|
||||
ResolveActor(),
|
||||
request.ToPreviewCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("reconciliation/import")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("导入支付渠道账单并创建对账批次")]
|
||||
[ProducesResponseType<CommerceReconciliationBatch>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceReconciliationBatch>> ImportReconciliation(
|
||||
PreviewReconciliationImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.ImportReconciliationAsync(
|
||||
ResolveActor(),
|
||||
request.ToImportCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("reconciliation/provider-bills/jobs")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询渠道账单下载任务")]
|
||||
[ProducesResponseType<IReadOnlyCollection<BackgroundJobItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> ProviderBillJobs(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.GetProviderBillJobsAsync(
|
||||
ResolveActor(),
|
||||
ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("reconciliation/provider-bills/request")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("创建渠道官方账单下载任务")]
|
||||
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackgroundJobItem>> RequestProviderBill(
|
||||
RequestProviderBillJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.RequestProviderBillJobAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private CommerceAdminActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
|
||||
@@ -7,6 +7,7 @@ using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Content;
|
||||
@@ -235,6 +236,16 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is PlatformAdminException platformAdminException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
platformAdminException.Message,
|
||||
PlatformAdminStatusCode(platformAdminException.Code),
|
||||
platformAdminException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is CommerceException commerceException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -469,6 +480,17 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
};
|
||||
}
|
||||
|
||||
private static int PlatformAdminStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"platform_access_denied" => StatusCodes.Status403Forbidden,
|
||||
"tenant_slug_exists" => StatusCodes.Status409Conflict,
|
||||
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
private static int PointStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
@@ -180,6 +181,81 @@ public sealed record CreateReconciliationBatchCommand(
|
||||
|
||||
public sealed record TenantReconciliationBatchList(IReadOnlyCollection<CommerceReconciliationBatch> Items);
|
||||
public sealed record TenantReconciliationIssueList(IReadOnlyCollection<CommerceReconciliationIssue> Items);
|
||||
public sealed record TenantReconciliationItemList(IReadOnlyCollection<CommerceReconciliationItem> Items);
|
||||
public sealed record TenantReconciliationIssueEventList(IReadOnlyCollection<CommerceReconciliationIssueEvent> Items);
|
||||
public sealed record TenantCommerceAnomalySummary(
|
||||
int OpenRefundCount,
|
||||
int ProcessingRefundCount,
|
||||
int OpenReconciliationIssueCount,
|
||||
int FailedReconciliationBatchCount,
|
||||
int PendingPaymentCount,
|
||||
int PaymentMismatchCount);
|
||||
|
||||
public sealed record CreateAdjustmentVoucherCommand(
|
||||
Guid? IssueId,
|
||||
Guid? BatchId,
|
||||
Guid? ItemId,
|
||||
Guid? OrderId,
|
||||
Guid? PaymentId,
|
||||
Guid? RefundRequestId,
|
||||
CommerceAdjustmentDirection Direction,
|
||||
int AmountCents,
|
||||
string? Currency,
|
||||
string Reason,
|
||||
string? ProofAssetKey,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record UpdateAdjustmentVoucherStatusCommand(
|
||||
Guid VoucherId,
|
||||
CommerceAdjustmentVoucherStatus Status,
|
||||
string? Note);
|
||||
|
||||
public sealed record TenantAdjustmentVoucherList(IReadOnlyCollection<CommerceAdjustmentVoucher> Items);
|
||||
public sealed record TenantAdjustmentVoucherEventList(IReadOnlyCollection<CommerceAdjustmentVoucherEvent> Items);
|
||||
public sealed record TenantAdjustmentReport(
|
||||
int DraftCount,
|
||||
int PendingReviewCount,
|
||||
int ApprovedCount,
|
||||
int ClosedCount,
|
||||
int IncreaseRevenueCents,
|
||||
int DecreaseRevenueCents);
|
||||
|
||||
public sealed record PreviewReconciliationImportCommand(
|
||||
string Provider,
|
||||
DateOnly BillDate,
|
||||
ReconciliationBillType BillType,
|
||||
string SourceName,
|
||||
JsonElement Rows);
|
||||
|
||||
public sealed record ReconciliationImportPreview(
|
||||
int TotalCount,
|
||||
int PaymentCount,
|
||||
int RefundCount,
|
||||
int InvalidCount,
|
||||
int AmountCents,
|
||||
int RefundAmountCents,
|
||||
string SourceHash);
|
||||
|
||||
public sealed record ImportReconciliationCommand(
|
||||
string Provider,
|
||||
DateOnly BillDate,
|
||||
ReconciliationBillType BillType,
|
||||
string SourceName,
|
||||
JsonElement Rows);
|
||||
|
||||
public sealed record RequestProviderBillJobCommand(
|
||||
string Provider,
|
||||
DateOnly BillDate,
|
||||
ReconciliationBillType BillType,
|
||||
DateTimeOffset? RunAfter = null);
|
||||
|
||||
public sealed record RefundNotificationCommand(
|
||||
string Provider,
|
||||
string RefundNo,
|
||||
string? ProviderRefundNo,
|
||||
CommerceRefundStatus Status,
|
||||
string? EventId,
|
||||
JsonElement Payload);
|
||||
|
||||
public sealed record UpdateReconciliationIssueCommand(
|
||||
Guid IssueId,
|
||||
@@ -324,4 +400,72 @@ public interface ICommerceAdminService
|
||||
CommerceAdminActor actor,
|
||||
UpdateReconciliationIssueCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantReconciliationItemList> GetReconciliationItemsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid batchId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantReconciliationIssueEventList> GetReconciliationIssueEventsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid issueId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantCommerceAnomalySummary> GetAnomalySummaryAsync(
|
||||
CommerceAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdjustmentVoucherList> GetAdjustmentVouchersAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceAdjustmentVoucher> GetAdjustmentVoucherAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid voucherId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceAdjustmentVoucher> CreateAdjustmentVoucherAsync(
|
||||
CommerceAdminActor actor,
|
||||
CreateAdjustmentVoucherCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceAdjustmentVoucher> UpdateAdjustmentVoucherStatusAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdateAdjustmentVoucherStatusCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdjustmentVoucherEventList> GetAdjustmentVoucherEventsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid voucherId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdjustmentReport> GetAdjustmentReportAsync(
|
||||
CommerceAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReconciliationImportPreview> PreviewReconciliationImportAsync(
|
||||
CommerceAdminActor actor,
|
||||
PreviewReconciliationImportCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceReconciliationBatch> ImportReconciliationAsync(
|
||||
CommerceAdminActor actor,
|
||||
ImportReconciliationCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BackgroundJobItem> RequestProviderBillJobAsync(
|
||||
CommerceAdminActor actor,
|
||||
RequestProviderBillJobCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<BackgroundJobItem>> GetProviderBillJobsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CommerceRefundRequest> ProcessRefundNotificationAsync(
|
||||
Guid tenantId,
|
||||
RefundNotificationCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
262
Tiku.Application/PlatformAdmin/PlatformAdminModels.cs
Normal file
262
Tiku.Application/PlatformAdmin/PlatformAdminModels.cs
Normal file
@@ -0,0 +1,262 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
|
||||
namespace Tiku.Application.PlatformAdmin;
|
||||
|
||||
public sealed record PlatformAdminActor(Guid UserId);
|
||||
|
||||
public sealed record PlatformAdminQuery(string? Status = null, string? Search = null, int? Limit = null);
|
||||
|
||||
public sealed record PlatformOverview(
|
||||
int TenantCount,
|
||||
int ActiveTenantCount,
|
||||
int SuspendedTenantCount,
|
||||
int OrderCount,
|
||||
int PaidOrderCount,
|
||||
int RevenueCents,
|
||||
int QuestionBankCount,
|
||||
int QuestionCount,
|
||||
int LearningActiveUserCount);
|
||||
|
||||
public sealed record PlatformTenantList(IReadOnlyCollection<PlatformTenantItem> Items);
|
||||
|
||||
public sealed record PlatformTenantItem(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
string Name,
|
||||
string? LegalName,
|
||||
TenantStatus Status,
|
||||
TenantMode Mode,
|
||||
BillingStatus BillingStatus,
|
||||
DateTimeOffset? SubscriptionExpiresAt,
|
||||
int DomainCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record PlatformTenantDetail(
|
||||
PlatformTenantItem Tenant,
|
||||
IReadOnlyCollection<PlatformTenantDomainItem> Domains,
|
||||
IReadOnlyCollection<PlatformTenantSubscriptionItem> Subscriptions,
|
||||
TenantBillingProfileItem? BillingProfile);
|
||||
|
||||
public sealed record PlatformTenantDomainItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string Host,
|
||||
TenantDomainType DomainType,
|
||||
TenantDomainStatus Status,
|
||||
bool IsPrimary,
|
||||
DateTimeOffset? VerifiedAt,
|
||||
DateTimeOffset? DnsVerifiedAt,
|
||||
DateTimeOffset? TlsReadyAt,
|
||||
DateTimeOffset? LastCheckedAt,
|
||||
string? LastFailureReason);
|
||||
|
||||
public sealed record PlatformTenantSubscriptionItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string PlanCode,
|
||||
TenantSubscriptionStatus Status,
|
||||
DateTimeOffset? StartsAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? BillingCycle,
|
||||
int AmountCents,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record TenantBillingProfileItem(
|
||||
Guid TenantId,
|
||||
string? BillingName,
|
||||
string? TaxId,
|
||||
string? ContactName,
|
||||
string? ContactPhoneMasked,
|
||||
string? ContactEmail,
|
||||
string? BillingAddress,
|
||||
string? InvoiceTitle,
|
||||
TenantInvoiceTitleType? InvoiceType,
|
||||
string? BankName,
|
||||
string? BankAccountMasked,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record CreatePlatformTenantCommand(
|
||||
string Slug,
|
||||
string Name,
|
||||
string? LegalName,
|
||||
TenantStatus Status,
|
||||
BillingStatus BillingStatus,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record UpdatePlatformTenantStatusCommand(
|
||||
Guid TenantId,
|
||||
TenantStatus Status,
|
||||
BillingStatus BillingStatus,
|
||||
string? Reason);
|
||||
|
||||
public sealed record UpsertPlatformTenantBillingProfileCommand(
|
||||
Guid TenantId,
|
||||
string? BillingName,
|
||||
string? TaxId,
|
||||
string? ContactName,
|
||||
string? ContactPhone,
|
||||
string? ContactEmail,
|
||||
string? BillingAddress,
|
||||
string? InvoiceTitle,
|
||||
TenantInvoiceTitleType? InvoiceType,
|
||||
string? BankName,
|
||||
string? BankAccountMasked,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformPlanList(IReadOnlyCollection<PlatformSaasPlan> Items);
|
||||
|
||||
public sealed record UpsertPlatformSubscriptionCommand(
|
||||
Guid TenantId,
|
||||
string PlanCode,
|
||||
TenantSubscriptionStatus Status,
|
||||
DateTimeOffset? StartsAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? BillingCycle,
|
||||
int AmountCents,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformDomainList(IReadOnlyCollection<PlatformTenantDomainItem> Items);
|
||||
|
||||
public sealed record PlatformDomainRecheckResult(Guid DomainId, TenantDomainStatus Status, DateTimeOffset LastCheckedAt);
|
||||
|
||||
public sealed record PlatformStaffList(IReadOnlyCollection<PlatformStaffItem> Items);
|
||||
|
||||
public sealed record PlatformStaffItem(
|
||||
Guid UserId,
|
||||
string? Name,
|
||||
string? PhoneMasked,
|
||||
string? Email,
|
||||
UserStatus Status,
|
||||
IReadOnlyCollection<string> RoleCodes,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record UpsertPlatformStaffCommand(
|
||||
Guid? UserId,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string? Name,
|
||||
UserStatus Status,
|
||||
IReadOnlyCollection<Guid> RoleIds);
|
||||
|
||||
public sealed record UpdatePlatformStaffStatusCommand(Guid UserId, UserStatus Status, string? Reason);
|
||||
|
||||
public sealed record PlatformAuditLogList(IReadOnlyCollection<PlatformAuditLogItem> Items);
|
||||
|
||||
public sealed record PlatformAuditLogItem(
|
||||
Guid Id,
|
||||
Guid? TenantId,
|
||||
Guid? ActorUserId,
|
||||
string Action,
|
||||
string? TargetType,
|
||||
string? TargetId,
|
||||
JsonElement Details,
|
||||
string? IpAddress,
|
||||
string? UserAgent,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record PlatformAuditAlertList(IReadOnlyCollection<PlatformAuditAlert> Items);
|
||||
|
||||
public sealed record UpdatePlatformAuditAlertStatusCommand(
|
||||
Guid AlertId,
|
||||
PlatformAuditAlertStatus Status,
|
||||
string? ResolutionNote);
|
||||
|
||||
public sealed record PlatformDunningChannelList(IReadOnlyCollection<PlatformDunningChannelItem> Items);
|
||||
|
||||
public sealed record PlatformDunningChannelItem(
|
||||
Guid Id,
|
||||
string ChannelCode,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
PlatformDunningProvider Provider,
|
||||
string WebhookUrlMasked,
|
||||
string? SecretRef,
|
||||
IReadOnlyCollection<string> ReminderTypes,
|
||||
IReadOnlyCollection<string> ReminderChannels,
|
||||
int MinReminderLevel,
|
||||
IReadOnlyCollection<Guid> TenantIds,
|
||||
int TimeoutSeconds,
|
||||
JsonElement Metadata,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record UpsertPlatformDunningChannelCommand(
|
||||
Guid? ChannelId,
|
||||
string ChannelCode,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
PlatformDunningProvider Provider,
|
||||
string WebhookUrl,
|
||||
string? SecretRef,
|
||||
IReadOnlyCollection<string> ReminderTypes,
|
||||
IReadOnlyCollection<string> ReminderChannels,
|
||||
int MinReminderLevel,
|
||||
IReadOnlyCollection<Guid> TenantIds,
|
||||
int TimeoutSeconds,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record DisablePlatformDunningChannelCommand(Guid ChannelId, string? Reason);
|
||||
|
||||
public sealed record PlatformDunningEventList(IReadOnlyCollection<PlatformDunningEventItem> Items);
|
||||
|
||||
public sealed record PlatformDunningEventItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
Guid ChannelId,
|
||||
Guid ReminderId,
|
||||
Guid InvoiceId,
|
||||
PlatformDunningProvider Provider,
|
||||
PlatformDunningNotificationStatus Status,
|
||||
int Attempts,
|
||||
DateTimeOffset ScheduledAt,
|
||||
DateTimeOffset? NextAttemptAt,
|
||||
DateTimeOffset? LastAttemptAt,
|
||||
DateTimeOffset? SentAt,
|
||||
string? LastError,
|
||||
int? LastHttpCode,
|
||||
string? LastResponseSummary,
|
||||
JsonElement RequestPayload,
|
||||
JsonElement Metadata,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record RetryPlatformDunningEventCommand(Guid EventId, string? Reason);
|
||||
|
||||
public interface IPlatformAdminService
|
||||
{
|
||||
Task<PlatformOverview> GetOverviewAsync(PlatformAdminActor actor, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantList> GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantDetail> GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantItem> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPlanList> GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainList> GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformStaffItem> UpsertStaffAsync(PlatformAdminActor actor, UpsertPlatformStaffCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformStaffItem> UpdateStaffStatusAsync(PlatformAdminActor actor, UpdatePlatformStaffStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformAuditLogList> GetAuditLogsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformAuditAlertList> GetAuditAlertsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(PlatformAdminActor actor, UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelList> GetDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelItem> DisableDunningChannelAsync(PlatformAdminActor actor, DisablePlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventList> GetDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventItem> GetDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventItem> RetryDunningEventAsync(PlatformAdminActor actor, RetryPlatformDunningEventCommand command, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformAdminException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public static class BackendPermissions
|
||||
public const string PlatformRoleManage = "platform:role:manage";
|
||||
public const string PlatformQuestionBankManage = "platform:question-bank:manage";
|
||||
public const string PlatformAuditView = "platform:audit:view";
|
||||
public const string PlatformBillingNotification = "platform:billing:notification";
|
||||
|
||||
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -43,7 +44,8 @@ public static class BackendPermissions
|
||||
PlatformStaffManage,
|
||||
PlatformRoleManage,
|
||||
PlatformQuestionBankManage,
|
||||
PlatformAuditView
|
||||
PlatformAuditView,
|
||||
PlatformBillingNotification
|
||||
};
|
||||
|
||||
public static void EnsureTenant(string permissionCode)
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace Tiku.Application.TenantAdmin;
|
||||
|
||||
public interface ITenantAdminDirectService
|
||||
{
|
||||
Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminClassList> GetClassesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminClassFilter filter,
|
||||
@@ -50,6 +54,56 @@ public interface ITenantAdminDirectService
|
||||
UpdateTenantAdminStudentStatusCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminStudentImportPreview> PreviewStudentImportAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminStudentImportResult> ImportStudentsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminBulkOperationResult> BulkAssignClassAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminBulkAssignClassCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantAdminBulkOperationResult> BulkUpdateStudentStatusAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminBulkStatusCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<TenantSupervisionRuleItem>> GetSupervisionRulesAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<TenantSupervisionRuleItem>> UpsertSupervisionRuleAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantSupervisionRuleCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantSupervisionPreview> PreviewSupervisionAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantSupervisionGenerateResult> GenerateSupervisionFollowupsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantSupervisionGenerateCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantFollowupReport> GetFollowupReportAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantFeedbackReport> GetFeedbackReportAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TenantPointRiskReport> GetPointRiskReportAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentActivityFilter filter,
|
||||
|
||||
@@ -78,6 +78,20 @@ public sealed record TenantAdminFeedbackFilter(
|
||||
string? Keyword = null,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record TenantAdminOverviewItem(
|
||||
int StudentCount,
|
||||
int ClassCount,
|
||||
int StaffCount,
|
||||
int ActivePracticeCount,
|
||||
int TodayPracticeCount,
|
||||
int PendingFollowupCount,
|
||||
int UnreadNotificationCount,
|
||||
int PaidOrderCount,
|
||||
int RevenueCents,
|
||||
int PendingRefundCount,
|
||||
int OpenReconciliationIssueCount,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
public sealed record UpsertTenantAdminClassCommand(
|
||||
Guid? Id,
|
||||
Guid? RegionId,
|
||||
@@ -120,6 +134,109 @@ public sealed record UpdateTenantAdminStudentStatusCommand(
|
||||
string? Status,
|
||||
string? Reason);
|
||||
|
||||
public sealed record TenantAdminStudentImportRowCommand(
|
||||
UserLookupCommand User,
|
||||
Guid? RegionId,
|
||||
Guid? ClassId,
|
||||
string? AvatarPreset,
|
||||
JsonElement RawProfile);
|
||||
|
||||
public sealed record TenantAdminStudentImportCommand(
|
||||
IReadOnlyCollection<TenantAdminStudentImportRowCommand> Rows);
|
||||
|
||||
public sealed record TenantAdminStudentImportPreview(
|
||||
int TotalCount,
|
||||
int ValidCount,
|
||||
int InvalidCount,
|
||||
IReadOnlyCollection<TenantAdminStudentImportPreviewItem> Items);
|
||||
|
||||
public sealed record TenantAdminStudentImportPreviewItem(
|
||||
int RowNo,
|
||||
bool Valid,
|
||||
string? Reason,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? Name,
|
||||
Guid? RegionId,
|
||||
Guid? ClassId);
|
||||
|
||||
public sealed record TenantAdminStudentImportResult(
|
||||
int TotalCount,
|
||||
int CreatedOrUpdatedCount,
|
||||
int ClassAssignedCount,
|
||||
IReadOnlyCollection<TenantAdminStudentImportPreviewItem> InvalidItems);
|
||||
|
||||
public sealed record TenantAdminBulkAssignClassCommand(
|
||||
Guid ClassId,
|
||||
IReadOnlyCollection<Guid> UserIds);
|
||||
|
||||
public sealed record TenantAdminBulkStatusCommand(
|
||||
IReadOnlyCollection<Guid> UserIds,
|
||||
string? Status,
|
||||
string? Reason);
|
||||
|
||||
public sealed record TenantAdminBulkOperationResult(
|
||||
int RequestedCount,
|
||||
int SucceededCount,
|
||||
int FailedCount,
|
||||
IReadOnlyCollection<Guid> FailedUserIds);
|
||||
|
||||
public sealed record TenantSupervisionRuleItem(
|
||||
string Code,
|
||||
string Title,
|
||||
bool Enabled,
|
||||
int? DaysWithoutCheckIn,
|
||||
int? MaxQuestionsAnsweredToday,
|
||||
string FollowupType,
|
||||
string Priority,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record UpsertTenantSupervisionRuleCommand(
|
||||
string Code,
|
||||
string Title,
|
||||
bool Enabled,
|
||||
int? DaysWithoutCheckIn,
|
||||
int? MaxQuestionsAnsweredToday,
|
||||
string? FollowupType,
|
||||
string? Priority,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record TenantSupervisionPreview(
|
||||
IReadOnlyCollection<TenantSupervisionRiskStudentItem> Items);
|
||||
|
||||
public sealed record TenantSupervisionRiskStudentItem(
|
||||
Guid UserId,
|
||||
string? Name,
|
||||
string? PhoneMasked,
|
||||
Guid? RegionId,
|
||||
IReadOnlyCollection<string> RuleCodes,
|
||||
IReadOnlyCollection<string> Reasons);
|
||||
|
||||
public sealed record TenantSupervisionGenerateCommand(
|
||||
IReadOnlyCollection<Guid>? UserIds,
|
||||
Guid? AssignedToUserId,
|
||||
DateTimeOffset? DueAt);
|
||||
|
||||
public sealed record TenantSupervisionGenerateResult(int CreatedCount);
|
||||
|
||||
public sealed record TenantFollowupReport(
|
||||
int OpenCount,
|
||||
int InProgressCount,
|
||||
int DoneCount,
|
||||
int OverdueCount);
|
||||
|
||||
public sealed record TenantFeedbackReport(
|
||||
int PendingCount,
|
||||
int AcceptedCount,
|
||||
int RejectedCount,
|
||||
int ResolvedCount,
|
||||
int ClosedCount);
|
||||
|
||||
public sealed record TenantPointRiskReport(
|
||||
int NegativeScoreUserCount,
|
||||
int HighPointClaimUserCount,
|
||||
int CancelledExchangeOrderCount);
|
||||
|
||||
public sealed record UpsertTenantAdminStudentNoteCommand(
|
||||
Guid? Id,
|
||||
Guid StudentUserId,
|
||||
|
||||
@@ -348,6 +348,40 @@ public sealed class CommerceReconciliationIssueEvent : Entity, ITenantOwned
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class CommerceAdjustmentVoucher : AuditableTenantEntity
|
||||
{
|
||||
public Guid? IssueId { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
public Guid? ItemId { get; set; }
|
||||
public Guid? OrderId { get; set; }
|
||||
public Guid? PaymentId { get; set; }
|
||||
public Guid? RefundRequestId { get; set; }
|
||||
public Guid? CreatedBy { get; set; }
|
||||
public Guid? ReviewedBy { get; set; }
|
||||
public string VoucherNo { get; set; } = string.Empty;
|
||||
public CommerceAdjustmentVoucherStatus Status { get; set; } = CommerceAdjustmentVoucherStatus.Draft;
|
||||
public CommerceAdjustmentDirection Direction { get; set; } = CommerceAdjustmentDirection.IncreaseRevenue;
|
||||
public int AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string? ProofAssetKey { get; set; }
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
public DateTimeOffset? ClosedAt { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class CommerceAdjustmentVoucherEvent : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid VoucherId { get; set; }
|
||||
public CommerceAdjustmentVoucherStatus? FromStatus { get; set; }
|
||||
public CommerceAdjustmentVoucherStatus ToStatus { get; set; } = CommerceAdjustmentVoucherStatus.Draft;
|
||||
public Guid? ActorUserId { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public JsonElement Details { get; set; } = JsonDefaults.Object();
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public enum ProductType { Material, Course, Service, Link, Other }
|
||||
public enum OrderStatus { Pending, Paid, Failed, Closed, PartiallyRefunded, Refunded }
|
||||
public enum PaymentStatus { Pending, Paid, Failed, Cancelled, PartiallyRefunded, Refunded }
|
||||
@@ -367,3 +401,5 @@ public enum ReconciliationIssueMatchStatus { AmountMismatch, StatusMismatch, Mis
|
||||
public enum NotificationSeverity { Info, Warning, Error, Critical }
|
||||
public enum ReconciliationIssueStatus { Open, Investigating, Resolved, Ignored, Escalated }
|
||||
public enum ReconciliationResolutionType { None, ProviderConfirmed, LocalCorrected, ManualAdjustment, FalsePositive, Duplicate, WriteOff }
|
||||
public enum CommerceAdjustmentVoucherStatus { Draft, PendingReview, Approved, Rejected, Closed, Void }
|
||||
public enum CommerceAdjustmentDirection { IncreaseRevenue, DecreaseRevenue, IncreaseRefund, DecreaseRefund, WriteOff }
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Catalog;
|
||||
@@ -17,7 +18,8 @@ internal sealed class CommerceAdminService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantSecretProtector tenantSecretProtector,
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
ICurrentAccessContext currentAccessContext) : ICommerceAdminService
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IBackgroundJobService backgroundJobService) : ICommerceAdminService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
|
||||
CommerceAdminActor actor,
|
||||
@@ -871,6 +873,426 @@ internal sealed class CommerceAdminService(
|
||||
return issue;
|
||||
}
|
||||
|
||||
public async Task<TenantAdjustmentVoucherList> GetAdjustmentVouchersAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await vouchers
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantAdjustmentVoucherList(items);
|
||||
}
|
||||
|
||||
public async Task<CommerceAdjustmentVoucher> GetAdjustmentVoucherAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid voucherId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return await dbContext.CommerceAdjustmentVouchers.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken)
|
||||
?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
}
|
||||
|
||||
public async Task<CommerceAdjustmentVoucher> CreateAdjustmentVoucherAsync(
|
||||
CommerceAdminActor actor,
|
||||
CreateAdjustmentVoucherCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken);
|
||||
await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken);
|
||||
var voucher = new CommerceAdjustmentVoucher
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
IssueId = command.IssueId,
|
||||
BatchId = command.BatchId,
|
||||
ItemId = command.ItemId,
|
||||
OrderId = command.OrderId,
|
||||
PaymentId = command.PaymentId,
|
||||
RefundRequestId = command.RefundRequestId,
|
||||
CreatedBy = actor.UserId,
|
||||
VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}",
|
||||
Status = CommerceAdjustmentVoucherStatus.Draft,
|
||||
Direction = command.Direction,
|
||||
AmountCents = command.AmountCents,
|
||||
Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(),
|
||||
Reason = command.Reason.Trim(),
|
||||
ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(),
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.CommerceAdjustmentVouchers.Add(voucher);
|
||||
dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
VoucherId = voucher.Id,
|
||||
ToStatus = voucher.Status,
|
||||
ActorUserId = actor.UserId,
|
||||
Note = voucher.Reason,
|
||||
Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents })
|
||||
});
|
||||
await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return voucher;
|
||||
}
|
||||
|
||||
public async Task<CommerceAdjustmentVoucher> UpdateAdjustmentVoucherStatusAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdateAdjustmentVoucherStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.VoucherId,
|
||||
cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
var fromStatus = voucher.Status;
|
||||
if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status))
|
||||
{
|
||||
throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition");
|
||||
}
|
||||
|
||||
voucher.Status = command.Status;
|
||||
if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected)
|
||||
{
|
||||
voucher.ReviewedBy = actor.UserId;
|
||||
voucher.ReviewedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void)
|
||||
{
|
||||
voucher.ClosedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
VoucherId = voucher.Id,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = command.Status,
|
||||
ActorUserId = actor.UserId,
|
||||
Note = command.Note,
|
||||
Details = JsonSerializer.SerializeToElement(new { })
|
||||
});
|
||||
await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return voucher;
|
||||
}
|
||||
|
||||
public async Task<TenantAdjustmentVoucherEventList> GetAdjustmentVoucherEventsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid voucherId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == voucherId,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found");
|
||||
}
|
||||
|
||||
var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantAdjustmentVoucherEventList(events);
|
||||
}
|
||||
|
||||
public async Task<TenantAdjustmentReport> GetAdjustmentReportAsync(
|
||||
CommerceAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return new TenantAdjustmentReport(
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue)
|
||||
.SumAsync(item => item.AmountCents, cancellationToken),
|
||||
await dbContext.CommerceAdjustmentVouchers
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue)
|
||||
.SumAsync(item => item.AmountCents, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<TenantReconciliationItemList> GetReconciliationItemsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid batchId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == batchId,
|
||||
cancellationToken);
|
||||
if (!batchExists)
|
||||
{
|
||||
throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found");
|
||||
}
|
||||
|
||||
var items = await dbContext.CommerceReconciliationItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId)
|
||||
.OrderBy(item => item.RowNo)
|
||||
.Take(500)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantReconciliationItemList(items);
|
||||
}
|
||||
|
||||
public async Task<TenantReconciliationIssueEventList> GetReconciliationIssueEventsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid issueId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == issueId,
|
||||
cancellationToken);
|
||||
if (!issueExists)
|
||||
{
|
||||
throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
|
||||
}
|
||||
|
||||
var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantReconciliationIssueEventList(events);
|
||||
}
|
||||
|
||||
public async Task<TenantCommerceAnomalySummary> GetAnomalySummaryAsync(
|
||||
CommerceAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var openRefunds = await dbContext.CommerceRefundRequests.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested,
|
||||
cancellationToken);
|
||||
var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing,
|
||||
cancellationToken);
|
||||
var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored,
|
||||
cancellationToken);
|
||||
var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed,
|
||||
cancellationToken);
|
||||
var pendingPayments = await dbContext.Payments.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending,
|
||||
cancellationToken);
|
||||
var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync(
|
||||
item => item.TenantId == actor.TenantId &&
|
||||
(item.MatchStatus == ReconciliationMatchStatus.AmountMismatch ||
|
||||
item.MatchStatus == ReconciliationMatchStatus.StatusMismatch),
|
||||
cancellationToken);
|
||||
|
||||
return new TenantCommerceAnomalySummary(
|
||||
openRefunds,
|
||||
processingRefunds,
|
||||
openIssues,
|
||||
failedBatches,
|
||||
pendingPayments,
|
||||
mismatchCount);
|
||||
}
|
||||
|
||||
public async Task<ReconciliationImportPreview> PreviewReconciliationImportAsync(
|
||||
CommerceAdminActor actor,
|
||||
PreviewReconciliationImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return BuildImportPreview(command.Provider, command.Rows);
|
||||
}
|
||||
|
||||
public async Task<CommerceReconciliationBatch> ImportReconciliationAsync(
|
||||
CommerceAdminActor actor,
|
||||
ImportReconciliationCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var preview = BuildImportPreview(command.Provider, command.Rows);
|
||||
var batch = new CommerceReconciliationBatch
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
Provider = NormalizeProvider(command.Provider),
|
||||
BillDate = command.BillDate,
|
||||
BillType = command.BillType,
|
||||
Source = ReconciliationSource.ManualUpload,
|
||||
SourceName = command.SourceName.Trim(),
|
||||
SourceHash = preview.SourceHash,
|
||||
Status = preview.InvalidCount == 0
|
||||
? ReconciliationBatchStatus.Completed
|
||||
: ReconciliationBatchStatus.CompletedWithIssues,
|
||||
TotalCount = preview.TotalCount,
|
||||
MatchedCount = preview.TotalCount - preview.InvalidCount,
|
||||
MismatchCount = preview.InvalidCount,
|
||||
AmountCents = preview.AmountCents,
|
||||
RefundAmountCents = preview.RefundAmountCents,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
preview.PaymentCount,
|
||||
preview.RefundCount,
|
||||
preview.InvalidCount
|
||||
})
|
||||
};
|
||||
dbContext.CommerceReconciliationBatches.Add(batch);
|
||||
var rowNo = 0;
|
||||
foreach (var row in EnumerateImportRows(command.Rows))
|
||||
{
|
||||
rowNo++;
|
||||
var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row);
|
||||
dbContext.CommerceReconciliationItems.Add(item);
|
||||
if (item.MatchStatus != ReconciliationMatchStatus.Matched)
|
||||
{
|
||||
dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
BatchId = batch.Id,
|
||||
Provider = item.Provider,
|
||||
TransactionType = item.TransactionType,
|
||||
IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}",
|
||||
MatchStatus = item.MatchStatus switch
|
||||
{
|
||||
ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal,
|
||||
ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider,
|
||||
ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate,
|
||||
ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch,
|
||||
_ => ReconciliationIssueMatchStatus.AmountMismatch
|
||||
},
|
||||
Severity = item.Severity,
|
||||
Status = ReconciliationIssueStatus.Open,
|
||||
OrderNo = item.OrderNo,
|
||||
RefundNo = item.RefundNo,
|
||||
ProviderTradeNo = item.ProviderTradeNo,
|
||||
ProviderRefundNo = item.ProviderRefundNo,
|
||||
AmountCents = item.AmountCents,
|
||||
RefundAmountCents = item.RefundAmountCents,
|
||||
Summary = item.IssueCode,
|
||||
CreatedBy = actor.UserId,
|
||||
Metadata = item.Details
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return batch;
|
||||
}
|
||||
|
||||
public async Task<BackgroundJobItem> RequestProviderBillJobAsync(
|
||||
CommerceAdminActor actor,
|
||||
RequestProviderBillJobCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var job = await backgroundJobService.EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
actor.TenantId,
|
||||
"commerce_reconciliation",
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
provider = NormalizeProvider(command.Provider),
|
||||
command.BillDate,
|
||||
billType = command.BillType.ToString()
|
||||
}),
|
||||
command.RunAfter,
|
||||
5),
|
||||
cancellationToken);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return job;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<BackgroundJobItem>> GetProviderBillJobsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return await backgroundJobService.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CommerceRefundRequest> ProcessRefundNotificationAsync(
|
||||
Guid tenantId,
|
||||
RefundNotificationCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var provider = NormalizeProvider(command.Provider);
|
||||
var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.RefundNo == command.RefundNo,
|
||||
cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
var eventId = string.IsNullOrWhiteSpace(command.EventId)
|
||||
? $"{provider}:{command.RefundNo}:{command.Status}"
|
||||
: command.EventId.Trim();
|
||||
var duplicate = await dbContext.PaymentEvents.AnyAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.Provider == provider &&
|
||||
item.EventType == "refund" &&
|
||||
item.EventId == eventId,
|
||||
cancellationToken);
|
||||
if (duplicate)
|
||||
{
|
||||
return refund;
|
||||
}
|
||||
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Provider = provider,
|
||||
EventType = "refund",
|
||||
EventId = eventId,
|
||||
SignatureValid = true,
|
||||
Payload = JsonObjectOrDefault(command.Payload),
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
var fromStatus = refund.Status;
|
||||
if (fromStatus != command.Status && IsAllowedRefundTransition(fromStatus, command.Status))
|
||||
{
|
||||
refund.Status = command.Status;
|
||||
refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
|
||||
? refund.ProviderRefundNo
|
||||
: command.ProviderRefundNo.Trim();
|
||||
if (command.Status == CommerceRefundStatus.Succeeded)
|
||||
{
|
||||
refund.SucceededAt = DateTimeOffset.UtcNow;
|
||||
await ApplyRefundToOrderAsync(refund, cancellationToken);
|
||||
}
|
||||
else if (command.Status == CommerceRefundStatus.Failed)
|
||||
{
|
||||
refund.FailedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RefundRequestId = refund.Id,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = command.Status,
|
||||
EventType = "provider_notify",
|
||||
Details = JsonObjectOrDefault(command.Payload)
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
@@ -964,6 +1386,33 @@ internal sealed class CommerceAdminService(
|
||||
? parsed
|
||||
: throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status");
|
||||
|
||||
private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) =>
|
||||
Enum.TryParse<CommerceAdjustmentVoucherStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status");
|
||||
|
||||
private 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);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await dbContext.Orders.SingleAsync(
|
||||
@@ -1004,6 +1453,17 @@ internal sealed class CommerceAdminService(
|
||||
};
|
||||
}
|
||||
|
||||
private 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
|
||||
};
|
||||
}
|
||||
|
||||
private void AddRefundEvent(
|
||||
CommerceRefundRequest refund,
|
||||
CommerceRefundStatus? fromStatus,
|
||||
@@ -1125,6 +1585,164 @@ internal sealed class CommerceAdminService(
|
||||
}
|
||||
}
|
||||
|
||||
private 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(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant();
|
||||
return new ReconciliationImportPreview(
|
||||
parsedRows.Length,
|
||||
paymentCount,
|
||||
refundCount,
|
||||
invalidCount,
|
||||
amountCents,
|
||||
refundAmountCents,
|
||||
sourceHash);
|
||||
}
|
||||
|
||||
private 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);
|
||||
|
||||
private 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());
|
||||
}
|
||||
}
|
||||
|
||||
private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) =>
|
||||
new(
|
||||
ReconciliationTransactionType.Payment,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
ReconciliationMatchStatus.AmountMismatch,
|
||||
issueCode,
|
||||
row.Clone());
|
||||
|
||||
private static CommerceReconciliationItem CreateReconciliationItem(
|
||||
Guid tenantId,
|
||||
Guid batchId,
|
||||
int rowNo,
|
||||
string provider,
|
||||
ReconciliationImportRow row) =>
|
||||
new()
|
||||
{
|
||||
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
|
||||
};
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private static string GenerateActivationCode()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
|
||||
@@ -15,6 +15,7 @@ using Tiku.Application.Notifications;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.StudyContent;
|
||||
@@ -34,6 +35,7 @@ using Tiku.Infrastructure.Notifications;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Profile;
|
||||
using Tiku.Infrastructure.Points;
|
||||
using Tiku.Infrastructure.PlatformAdmin;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Infrastructure.Security;
|
||||
@@ -113,6 +115,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -9,6 +14,7 @@ namespace Tiku.Infrastructure.Jobs;
|
||||
|
||||
internal sealed class BackgroundJobService(
|
||||
TikuDbContext dbContext,
|
||||
IServiceProvider serviceProvider,
|
||||
ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
@@ -56,7 +62,7 @@ internal sealed class BackgroundJobService(
|
||||
|
||||
try
|
||||
{
|
||||
await tenantExecutionScope.ExecuteAsync(
|
||||
var result = await tenantExecutionScope.ExecuteAsync(
|
||||
job.TenantId,
|
||||
$"Background job {job.JobType}",
|
||||
(_, token) => ProcessCoreAsync(job, token),
|
||||
@@ -64,7 +70,7 @@ internal sealed class BackgroundJobService(
|
||||
job.Status = BackgroundJobStatus.Succeeded;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = null;
|
||||
job.Result = JsonDefaults.Object();
|
||||
job.Result = result;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
@@ -108,26 +114,205 @@ internal sealed class BackgroundJobService(
|
||||
return jobs.Select(ToItem).ToArray();
|
||||
}
|
||||
|
||||
private static Task ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
|
||||
private async Task<JsonElement> ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return job.JobType switch
|
||||
{
|
||||
"content_export" => Task.CompletedTask,
|
||||
"content_import" => Task.CompletedTask,
|
||||
"asset_security_scan" => Task.CompletedTask,
|
||||
"statistics_aggregation" => Task.CompletedTask,
|
||||
"commerce_reconciliation" => Task.CompletedTask,
|
||||
"tenant_domain_recheck" => Task.CompletedTask,
|
||||
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
|
||||
"content_import" => throw new NotSupportedException("content_import requires the module-specific importer before it can mutate content."),
|
||||
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
|
||||
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(job, cancellationToken),
|
||||
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
|
||||
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(cancellationToken),
|
||||
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessContentExportAsync(
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exportType = GetJsonString(job.Payload, "exportType") ?? "summary";
|
||||
var assetKey = $"background-jobs/{job.Id:N}/content-export.json";
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == job.TenantId && item.AssetKey == assetKey,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
asset = new ContentAsset
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
AssetKey = assetKey,
|
||||
AssetType = ContentAssetType.Document,
|
||||
StorageProvider = AssetStorageProvider.ExternalUrl,
|
||||
UploadStatus = AssetUploadStatus.Verified,
|
||||
SecurityScanStatus = AssetSecurityScanStatus.NotRequired,
|
||||
Source = "background_job"
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
}
|
||||
|
||||
var questionBankCount = await dbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var questionCount = await dbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
var studentCount = await dbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken);
|
||||
asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
|
||||
asset.Title = "Content export manifest";
|
||||
asset.Description = $"Generated content export manifest for {exportType}.";
|
||||
asset.ObjectKey = assetKey;
|
||||
asset.MimeType = "application/json";
|
||||
asset.Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
exportType,
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
questionBankCount,
|
||||
questionCount,
|
||||
studentCount,
|
||||
payload = job.Payload
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
job.OutputAssetId = asset.Id;
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
outputAssetId = asset.Id,
|
||||
asset.AssetKey,
|
||||
questionBankCount,
|
||||
questionCount,
|
||||
studentCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessCommerceReconciliationAsync(
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var provider = NormalizeProvider(GetJsonString(job.Payload, "provider"));
|
||||
var hasProviderConfig = await dbContext.TenantExternalProviders.AnyAsync(
|
||||
item =>
|
||||
item.TenantId == job.TenantId &&
|
||||
item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment &&
|
||||
item.Provider == provider &&
|
||||
item.Status == Tiku.Domain.Tenancy.TenantExternalProviderStatus.Active,
|
||||
cancellationToken);
|
||||
if (!hasProviderConfig)
|
||||
{
|
||||
throw new InvalidOperationException($"Active payment provider '{provider}' is required for commerce reconciliation job.");
|
||||
}
|
||||
|
||||
var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date);
|
||||
var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined);
|
||||
var sourceHash = $"background-job:{job.Id:N}";
|
||||
var batch = await dbContext.CommerceReconciliationBatches.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == job.TenantId &&
|
||||
item.Provider == provider &&
|
||||
item.Source == ReconciliationSource.ProviderDownload &&
|
||||
item.SourceHash == sourceHash,
|
||||
cancellationToken);
|
||||
if (batch is null)
|
||||
{
|
||||
batch = new CommerceReconciliationBatch
|
||||
{
|
||||
TenantId = job.TenantId,
|
||||
Provider = provider,
|
||||
BillDate = billDate,
|
||||
BillType = billType,
|
||||
Source = ReconciliationSource.ProviderDownload,
|
||||
SourceName = $"provider-bill:{provider}:{billDate:yyyyMMdd}",
|
||||
SourceHash = sourceHash,
|
||||
Status = ReconciliationBatchStatus.Pending,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
jobId = job.Id,
|
||||
note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor."
|
||||
})
|
||||
};
|
||||
dbContext.CommerceReconciliationBatches.Add(batch);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
batchId = batch.Id,
|
||||
provider,
|
||||
billDate,
|
||||
billType = billType.ToString(),
|
||||
status = batch.Status.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessTenantDomainRecheckAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var lifecycleService = serviceProvider.GetRequiredService<ITenantDomainLifecycleService>();
|
||||
var processed = await lifecycleService.ProcessPendingAsync(cancellationToken);
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
processed
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-7);
|
||||
var activeLearnerCount = await dbContext.PracticeSessions
|
||||
.Where(item => item.TenantId == job.TenantId && item.StartedAt >= since)
|
||||
.Select(item => item.UserId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
var paidOrderCount = await dbContext.Orders
|
||||
.CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken);
|
||||
var revenueCents = await dbContext.Orders
|
||||
.Where(item => item.TenantId == job.TenantId &&
|
||||
(item.Status == OrderStatus.Paid ||
|
||||
item.Status == OrderStatus.PartiallyRefunded ||
|
||||
item.Status == OrderStatus.Refunded))
|
||||
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
since,
|
||||
activeLearnerCount,
|
||||
paidOrderCount,
|
||||
revenueCents
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeJobType(string jobType)
|
||||
{
|
||||
return jobType.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
{
|
||||
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return string.IsNullOrWhiteSpace(normalized)
|
||||
? throw new InvalidOperationException("Background job provider is required.")
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object &&
|
||||
element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName)
|
||||
{
|
||||
var value = GetJsonString(element, propertyName);
|
||||
return DateOnly.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static TEnum GetJsonEnum<TEnum>(JsonElement element, string propertyName, TEnum fallback)
|
||||
where TEnum : struct
|
||||
{
|
||||
var value = GetJsonString(element, propertyName);
|
||||
return Enum.TryParse<TEnum>(value, true, out var parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
private static BackgroundJobItem ToItem(BackgroundJob job)
|
||||
{
|
||||
return new BackgroundJobItem(
|
||||
|
||||
@@ -529,3 +529,70 @@ internal sealed class CommerceReconciliationIssueEventConfiguration : IEntityTyp
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CommerceAdjustmentVoucherConfiguration : IEntityTypeConfiguration<CommerceAdjustmentVoucher>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CommerceAdjustmentVoucher> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("commerce_adjustment_vouchers");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.VoucherNo).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Direction).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Currency).HasMaxLength(10);
|
||||
builder.Property(entity => entity.Reason).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.ProofAssetKey).HasMaxLength(500);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.VoucherNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.IssueId, entity.CreatedAt });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0"));
|
||||
builder.HasOne<CommerceReconciliationIssue>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.IssueId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<CommerceReconciliationBatch>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.BatchId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<CommerceReconciliationItem>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ItemId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<Order>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.OrderId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<Payment>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.PaymentId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<CommerceRefundRequest>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.RefundRequestId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ReviewedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CommerceAdjustmentVoucherEventConfiguration : IEntityTypeConfiguration<CommerceAdjustmentVoucherEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CommerceAdjustmentVoucherEvent> builder)
|
||||
{
|
||||
builder.ConfigureEntity("commerce_adjustment_voucher_events");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.Property(entity => entity.FromStatus).HasNullableSnakeCaseEnum();
|
||||
builder.Property(entity => entity.ToStatus).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Note).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.Details).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.VoucherId, entity.CreatedAt });
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<CommerceAdjustmentVoucher>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.VoucherId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ internal sealed class AuthLoginEventConfiguration : IEntityTypeConfiguration<Aut
|
||||
public void Configure(EntityTypeBuilder<AuthLoginEvent> builder)
|
||||
{
|
||||
builder.ConfigureEntity("auth_login_events");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.Property(entity => entity.Provider).HasMaxLength(50);
|
||||
builder.Property(entity => entity.Identifier).HasMaxLength(320);
|
||||
builder.Property(entity => entity.Result).HasSnakeCaseEnum();
|
||||
|
||||
@@ -182,7 +182,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("svip_question_limit");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
@@ -12956,9 +12956,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_auth_login_events");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_auth_login_events_tenant_id_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_auth_login_events_user_id");
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
background_color = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
@@ -441,7 +441,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
file_name = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
@@ -530,7 +530,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
identifier = table.Column<string>(type: "character varying(320)", maxLength: 320, nullable: true),
|
||||
@@ -544,7 +544,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_auth_login_events", x => x.id);
|
||||
table.UniqueConstraint("ak_auth_login_events_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_auth_login_events_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
|
||||
17959
Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.Designer.cs
generated
Normal file
17959
Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCommerceAdjustmentVouchers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "commerce_adjustment_vouchers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
issue_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
item_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
order_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
payment_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
refund_request_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
reviewed_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
voucher_no = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
direction = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
amount_cents = table.Column<int>(type: "integer", nullable: false),
|
||||
currency = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||
proof_asset_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
reviewed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
closed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_commerce_adjustment_vouchers", x => x.id);
|
||||
table.UniqueConstraint("ak_commerce_adjustment_vouchers_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~",
|
||||
columns: x => new { x.tenant_id, x.batch_id },
|
||||
principalTable: "commerce_reconciliation_batches",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~",
|
||||
columns: x => new { x.tenant_id, x.issue_id },
|
||||
principalTable: "commerce_reconciliation_issues",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~",
|
||||
columns: x => new { x.tenant_id, x.item_id },
|
||||
principalTable: "commerce_reconciliation_items",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~",
|
||||
columns: x => new { x.tenant_id, x.refund_request_id },
|
||||
principalTable: "commerce_refund_requests",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_orders_tenant_id_order_id",
|
||||
columns: x => new { x.tenant_id, x.order_id },
|
||||
principalTable: "orders",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id",
|
||||
columns: x => new { x.tenant_id, x.payment_id },
|
||||
principalTable: "payments",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_users_created_by",
|
||||
column: x => x.created_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_users_reviewed_by",
|
||||
column: x => x.reviewed_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "commerce_adjustment_voucher_events",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
voucher_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
from_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
to_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
actor_user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
note = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
details = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_commerce_adjustment_voucher_events", x => x.id);
|
||||
table.UniqueConstraint("ak_commerce_adjustment_voucher_events_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~",
|
||||
columns: x => new { x.tenant_id, x.voucher_id },
|
||||
principalTable: "commerce_adjustment_vouchers",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_voucher_events_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_voucher_events_users_actor_user_id",
|
||||
column: x => x.actor_user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_voucher_events_actor_user_id",
|
||||
table: "commerce_adjustment_voucher_events",
|
||||
column: "actor_user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~",
|
||||
table: "commerce_adjustment_voucher_events",
|
||||
columns: new[] { "tenant_id", "voucher_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_created_by",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
column: "created_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_reviewed_by",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
column: "reviewed_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_batch_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "batch_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "issue_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_item_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "item_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_order_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "order_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_payment_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "payment_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_refund_request_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "refund_request_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_status_created_at",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "status", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_voucher_no",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "voucher_no" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "commerce_adjustment_voucher_events");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "commerce_adjustment_vouchers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1338,6 +1338,221 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<int>("AmountCents")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("amount_cents");
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("batch_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ClosedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("closed_at");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<string>("Direction")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("direction");
|
||||
|
||||
b.Property<Guid?>("IssueId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("issue_id");
|
||||
|
||||
b.Property<Guid?>("ItemId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("item_id");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<Guid?>("OrderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("order_id");
|
||||
|
||||
b.Property<Guid?>("PaymentId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("payment_id");
|
||||
|
||||
b.Property<string>("ProofAssetKey")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("proof_asset_key");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<Guid?>("RefundRequestId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("refund_request_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReviewedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("reviewed_at");
|
||||
|
||||
b.Property<Guid?>("ReviewedBy")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("reviewed_by");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("VoucherNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("voucher_no");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_commerce_adjustment_vouchers");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_commerce_adjustment_vouchers_tenant_id_id");
|
||||
|
||||
b.HasIndex("CreatedBy")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_created_by");
|
||||
|
||||
b.HasIndex("ReviewedBy")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_reviewed_by");
|
||||
|
||||
b.HasIndex("TenantId", "BatchId")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_batch_id");
|
||||
|
||||
b.HasIndex("TenantId", "ItemId")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_item_id");
|
||||
|
||||
b.HasIndex("TenantId", "OrderId")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_order_id");
|
||||
|
||||
b.HasIndex("TenantId", "PaymentId")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_payment_id");
|
||||
|
||||
b.HasIndex("TenantId", "RefundRequestId")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_refund_request_id");
|
||||
|
||||
b.HasIndex("TenantId", "VoucherNo")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_voucher_no");
|
||||
|
||||
b.HasIndex("TenantId", "IssueId", "CreatedAt")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at");
|
||||
|
||||
b.HasIndex("TenantId", "Status", "CreatedAt")
|
||||
.HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_status_created_at");
|
||||
|
||||
b.ToTable("commerce_adjustment_vouchers", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<Guid?>("ActorUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("actor_user_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<JsonElement>("Details")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("details")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("FromStatus")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("from_status");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("note");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("ToStatus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("to_status");
|
||||
|
||||
b.Property<Guid>("VoucherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("voucher_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_commerce_adjustment_voucher_events");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_commerce_adjustment_voucher_events_tenant_id_id");
|
||||
|
||||
b.HasIndex("ActorUserId")
|
||||
.HasDatabaseName("ix_commerce_adjustment_voucher_events_actor_user_id");
|
||||
|
||||
b.HasIndex("TenantId", "VoucherId", "CreatedAt")
|
||||
.HasDatabaseName("ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~");
|
||||
|
||||
b.ToTable("commerce_adjustment_voucher_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -12937,7 +13152,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
b.Property<Guid?>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
@@ -12953,9 +13168,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_auth_login_events");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_auth_login_events_tenant_id_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_auth_login_events_user_id");
|
||||
|
||||
@@ -14640,6 +14852,94 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_code_batches_tenants_tenant_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatedBy")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_users_created_by");
|
||||
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewedBy")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_users_reviewed_by");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "BatchId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~");
|
||||
|
||||
b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationIssue", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "IssueId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~");
|
||||
|
||||
b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationItem", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "ItemId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~");
|
||||
|
||||
b.HasOne("Tiku.Domain.Commerce.Order", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "OrderId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_orders_tenant_id_order_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Commerce.Payment", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "PaymentId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "RefundRequestId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ActorUserId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_commerce_adjustment_voucher_events_users_actor_user_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_commerce_adjustment_voucher_events_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "VoucherId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
@@ -17401,7 +17701,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_auth_login_events_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
|
||||
@@ -136,6 +136,8 @@ public sealed class TikuDbContext(
|
||||
public DbSet<CommerceReconciliationItem> CommerceReconciliationItems => Set<CommerceReconciliationItem>();
|
||||
public DbSet<CommerceReconciliationIssue> CommerceReconciliationIssues => Set<CommerceReconciliationIssue>();
|
||||
public DbSet<CommerceReconciliationIssueEvent> CommerceReconciliationIssueEvents => Set<CommerceReconciliationIssueEvent>();
|
||||
public DbSet<CommerceAdjustmentVoucher> CommerceAdjustmentVouchers => Set<CommerceAdjustmentVoucher>();
|
||||
public DbSet<CommerceAdjustmentVoucherEvent> CommerceAdjustmentVoucherEvents => Set<CommerceAdjustmentVoucherEvent>();
|
||||
public DbSet<PointActivityTask> PointActivityTasks => Set<PointActivityTask>();
|
||||
public DbSet<PointActivityClaim> PointActivityClaims => Set<PointActivityClaim>();
|
||||
public DbSet<PointExchangeItem> PointExchangeItems => Set<PointExchangeItem>();
|
||||
|
||||
931
Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs
Normal file
931
Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs
Normal file
@@ -0,0 +1,931 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
|
||||
internal sealed class PlatformAdminService(
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
ITenantExecutionScope tenantExecutionScope) : IPlatformAdminService
|
||||
{
|
||||
public async Task<PlatformOverview> GetOverviewAsync(
|
||||
PlatformAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform overview", async dbContext =>
|
||||
{
|
||||
var tenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
|
||||
var activeTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Active, cancellationToken);
|
||||
var suspendedTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Suspended, cancellationToken);
|
||||
var orderCount = await dbContext.Orders.CountAsync(cancellationToken);
|
||||
var paidOrderCount = await dbContext.Orders.CountAsync(order => order.Status == OrderStatus.Paid, cancellationToken);
|
||||
var revenueCents = await dbContext.Orders
|
||||
.Where(order => order.Status == OrderStatus.Paid || order.Status == OrderStatus.PartiallyRefunded)
|
||||
.SumAsync(order => order.AmountCents - order.RefundedAmountCents, cancellationToken);
|
||||
var questionBankCount = await dbContext.QuestionBanks.CountAsync(cancellationToken);
|
||||
var questionCount = await dbContext.Questions.CountAsync(question => question.Status == QuestionStatus.Published, cancellationToken);
|
||||
var learningActiveUserCount = await dbContext.PracticeSessions
|
||||
.Where(session => session.StartedAt >= DateTimeOffset.UtcNow.AddDays(-7))
|
||||
.Select(session => session.UserId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
return new PlatformOverview(
|
||||
tenantCount,
|
||||
activeTenantCount,
|
||||
suspendedTenantCount,
|
||||
orderCount,
|
||||
paidOrderCount,
|
||||
revenueCents,
|
||||
questionBankCount,
|
||||
questionCount,
|
||||
learningActiveUserCount);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantList> GetTenantsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant list", async dbContext =>
|
||||
{
|
||||
var tenants = dbContext.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode != TenantMode.PlatformOwned);
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
tenants = tenants.Where(tenant =>
|
||||
tenant.Slug.Contains(search) ||
|
||||
tenant.Name.Contains(search) ||
|
||||
(tenant.LegalName != null && tenant.LegalName.Contains(search)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status));
|
||||
}
|
||||
|
||||
var rows = await tenants
|
||||
.OrderByDescending(tenant => tenant.CreatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(tenant => new
|
||||
{
|
||||
Tenant = tenant,
|
||||
DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id),
|
||||
SubscriptionExpiresAt = dbContext.TenantSubscriptions
|
||||
.Where(subscription => subscription.TenantId == tenant.Id)
|
||||
.OrderByDescending(subscription => subscription.ExpiresAt)
|
||||
.Select(subscription => subscription.ExpiresAt)
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray());
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantDetail> GetTenantDetailAsync(
|
||||
PlatformAdminActor actor,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant detail", async dbContext =>
|
||||
{
|
||||
var tenant = await dbContext.Tenants.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
var domains = await dbContext.TenantDomains.AsNoTracking()
|
||||
.Where(domain => domain.TenantId == tenantId)
|
||||
.OrderByDescending(domain => domain.IsPrimary)
|
||||
.ThenBy(domain => domain.Host)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var subscriptions = await dbContext.TenantSubscriptions.AsNoTracking()
|
||||
.Where(subscription => subscription.TenantId == tenantId)
|
||||
.OrderByDescending(subscription => subscription.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking()
|
||||
.SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken);
|
||||
|
||||
return new PlatformTenantDetail(
|
||||
ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.ExpiresAt),
|
||||
domains.Select(ToDomainItem).ToArray(),
|
||||
subscriptions.Select(ToSubscriptionItem).ToArray(),
|
||||
billingProfile is null ? null : ToBillingProfileItem(billingProfile));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantItem> CreateTenantAsync(
|
||||
PlatformAdminActor actor,
|
||||
CreatePlatformTenantCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant create", async dbContext =>
|
||||
{
|
||||
var slug = NormalizeCode(command.Slug);
|
||||
if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken))
|
||||
{
|
||||
throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists");
|
||||
}
|
||||
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Slug = slug,
|
||||
Name = command.Name.Trim(),
|
||||
LegalName = Normalize(command.LegalName),
|
||||
Status = command.Status,
|
||||
Mode = TenantMode.Saas,
|
||||
BillingStatus = command.BillingStatus,
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.Tenants.Add(tenant);
|
||||
AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToTenantItem(tenant, 0, null);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantItem> UpdateTenantStatusAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpdatePlatformTenantStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant status update", async dbContext =>
|
||||
{
|
||||
var tenant = await dbContext.Tenants
|
||||
.SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken)
|
||||
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
var fromStatus = tenant.Status;
|
||||
var fromBilling = tenant.BillingStatus;
|
||||
tenant.Status = command.Status;
|
||||
tenant.BillingStatus = command.BillingStatus;
|
||||
AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new
|
||||
{
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = tenant.Status,
|
||||
FromBillingStatus = fromBilling,
|
||||
ToBillingStatus = tenant.BillingStatus,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||
var expiresAt = await dbContext.TenantSubscriptions
|
||||
.Where(subscription => subscription.TenantId == tenant.Id)
|
||||
.OrderByDescending(subscription => subscription.ExpiresAt)
|
||||
.Select(subscription => subscription.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
return ToTenantItem(tenant, domainCount, expiresAt);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformTenantBillingProfileCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId,
|
||||
cancellationToken);
|
||||
if (profile is null)
|
||||
{
|
||||
profile = new TenantBillingProfile { TenantId = command.TenantId };
|
||||
dbContext.TenantBillingProfiles.Add(profile);
|
||||
}
|
||||
|
||||
profile.BillingName = Normalize(command.BillingName);
|
||||
profile.TaxId = Normalize(command.TaxId);
|
||||
profile.ContactName = Normalize(command.ContactName);
|
||||
profile.ContactPhone = Normalize(command.ContactPhone);
|
||||
profile.ContactEmail = Normalize(command.ContactEmail);
|
||||
profile.BillingAddress = Normalize(command.BillingAddress);
|
||||
profile.InvoiceTitle = Normalize(command.InvoiceTitle);
|
||||
profile.InvoiceType = command.InvoiceType;
|
||||
profile.BankName = Normalize(command.BankName);
|
||||
profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked);
|
||||
profile.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToBillingProfileItem(profile);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformPlanList> GetPlansAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform plan list", async dbContext =>
|
||||
{
|
||||
var plans = dbContext.PlatformSaasPlans.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
plans = plans.Where(plan => plan.Status == ParsePlanStatus(query.Status));
|
||||
}
|
||||
|
||||
return new PlatformPlanList(await plans
|
||||
.OrderBy(plan => plan.SortOrder)
|
||||
.ThenBy(plan => plan.Code)
|
||||
.Take(Limit(query.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformSubscriptionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext =>
|
||||
{
|
||||
await RequireTenantAsync(dbContext, command.TenantId, cancellationToken);
|
||||
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken))
|
||||
{
|
||||
throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found");
|
||||
}
|
||||
|
||||
var subscription = await dbContext.TenantSubscriptions.SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId && item.PlanCode == NormalizeCode(command.PlanCode),
|
||||
cancellationToken);
|
||||
if (subscription is null)
|
||||
{
|
||||
subscription = new TenantSubscription { TenantId = command.TenantId };
|
||||
dbContext.TenantSubscriptions.Add(subscription);
|
||||
}
|
||||
|
||||
subscription.PlanCode = NormalizeCode(command.PlanCode);
|
||||
subscription.Status = command.Status;
|
||||
subscription.StartsAt = command.StartsAt;
|
||||
subscription.ExpiresAt = command.ExpiresAt;
|
||||
subscription.BillingCycle = Normalize(command.BillingCycle);
|
||||
subscription.AmountCents = command.AmountCents;
|
||||
subscription.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
AddAudit(dbContext, actor, "platform.tenant.subscription.updated", command.TenantId, new
|
||||
{
|
||||
subscription.PlanCode,
|
||||
subscription.Status,
|
||||
subscription.ExpiresAt
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSubscriptionItem(subscription);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDomainList> GetDomainsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform domain list", async dbContext =>
|
||||
{
|
||||
var domains = dbContext.TenantDomains.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
domains = domains.Where(domain => domain.Host.Contains(search));
|
||||
}
|
||||
|
||||
return new PlatformDomainList(await domains
|
||||
.OrderByDescending(domain => domain.UpdatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(domain => ToDomainItem(domain))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDomainRecheckResult> RecheckDomainAsync(
|
||||
PlatformAdminActor actor,
|
||||
Guid domainId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform domain recheck", async dbContext =>
|
||||
{
|
||||
var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found");
|
||||
domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending;
|
||||
domain.LastCheckedAt = DateTimeOffset.UtcNow;
|
||||
domain.LastFailureReason = null;
|
||||
dbContext.BackgroundJobs.Add(new BackgroundJob
|
||||
{
|
||||
TenantId = domain.TenantId,
|
||||
JobType = "tenant_domain_recheck",
|
||||
Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }),
|
||||
MaxRetries = 3
|
||||
});
|
||||
AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformStaffList> GetStaffAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff list", async dbContext =>
|
||||
{
|
||||
var roleRows = await (
|
||||
from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id
|
||||
select new { user, role.Code })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var items = roleRows
|
||||
.GroupBy(row => row.user.Id)
|
||||
.Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray()))
|
||||
.OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString())
|
||||
.Take(Limit(query.Limit))
|
||||
.ToArray();
|
||||
return new PlatformStaffList(items);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformStaffItem> UpsertStaffAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformStaffCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff upsert", async dbContext =>
|
||||
{
|
||||
var user = command.UserId.HasValue
|
||||
? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken)
|
||||
: await dbContext.Users.SingleOrDefaultAsync(item =>
|
||||
(!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) ||
|
||||
(!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone),
|
||||
cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Email = Normalize(command.Email),
|
||||
NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(),
|
||||
UserName = Normalize(command.Email) ?? Normalize(command.Phone),
|
||||
NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(),
|
||||
Phone = Normalize(command.Phone),
|
||||
PhoneNumber = Normalize(command.Phone),
|
||||
Name = Normalize(command.Name),
|
||||
PrimaryRole = "platform_admin",
|
||||
Status = command.Status,
|
||||
ForcePasswordChange = true
|
||||
};
|
||||
dbContext.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.Email = Normalize(command.Email) ?? user.Email;
|
||||
user.NormalizedEmail = user.Email?.ToUpperInvariant();
|
||||
user.Phone = Normalize(command.Phone) ?? user.Phone;
|
||||
user.PhoneNumber = user.Phone;
|
||||
user.Name = Normalize(command.Name) ?? user.Name;
|
||||
user.Status = command.Status;
|
||||
user.PrimaryRole = "platform_admin";
|
||||
}
|
||||
|
||||
var roleIds = command.RoleIds.Distinct().ToArray();
|
||||
var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken);
|
||||
if (roleCount != roleIds.Length)
|
||||
{
|
||||
throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found");
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken);
|
||||
dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole
|
||||
{
|
||||
UserId = user.Id,
|
||||
RoleId = roleId
|
||||
}));
|
||||
AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking()
|
||||
.Where(role => roleIds.Contains(role.Id))
|
||||
.Select(role => role.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return ToStaffItem(user, roleCodes);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformStaffItem> UpdateStaffStatusAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpdatePlatformStaffStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform staff status update", async dbContext =>
|
||||
{
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found");
|
||||
var from = user.Status;
|
||||
user.Status = command.Status;
|
||||
AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var roleCodes = await (
|
||||
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
|
||||
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id
|
||||
where binding.UserId == user.Id
|
||||
select role.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return ToStaffItem(user, roleCodes);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformAuditLogList> GetAuditLogsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform audit log list", async dbContext =>
|
||||
{
|
||||
var logs = dbContext.AuditLogs.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search)));
|
||||
}
|
||||
|
||||
return new PlatformAuditLogList(await logs
|
||||
.OrderByDescending(log => log.CreatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(log => new PlatformAuditLogItem(
|
||||
log.Id,
|
||||
log.TenantId,
|
||||
log.ActorUserId,
|
||||
log.Action,
|
||||
log.TargetType,
|
||||
log.TargetId,
|
||||
log.Details,
|
||||
log.IpAddress,
|
||||
log.UserAgent,
|
||||
log.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformAuditAlertList> GetAuditAlertsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform audit alert list", async dbContext =>
|
||||
{
|
||||
var alerts = dbContext.PlatformAuditAlerts.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status));
|
||||
}
|
||||
|
||||
return new PlatformAuditAlertList(await alerts
|
||||
.OrderByDescending(alert => alert.LastSeenAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpdatePlatformAuditAlertStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform audit alert status update", async dbContext =>
|
||||
{
|
||||
var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found");
|
||||
alert.Status = command.Status;
|
||||
alert.ResolutionNote = Normalize(command.ResolutionNote);
|
||||
if (command.Status == PlatformAuditAlertStatus.Acknowledged)
|
||||
{
|
||||
alert.AcknowledgedBy = actor.UserId;
|
||||
alert.AcknowledgedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored)
|
||||
{
|
||||
alert.ResolvedBy = actor.UserId;
|
||||
alert.ResolvedAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote });
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return alert;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningChannelList> GetDunningChannelsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning channel list", async dbContext =>
|
||||
{
|
||||
var channels = dbContext.PlatformDunningNotificationChannels.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
channels = channels.Where(channel =>
|
||||
channel.ChannelCode.Contains(search) ||
|
||||
channel.Name.Contains(search));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
var enabled = ParseEnabledStatus(query.Status);
|
||||
channels = channels.Where(channel => channel.Enabled == enabled);
|
||||
}
|
||||
|
||||
return new PlatformDunningChannelList(await channels
|
||||
.OrderByDescending(channel => channel.Enabled)
|
||||
.ThenBy(channel => channel.MinReminderLevel)
|
||||
.ThenBy(channel => channel.ChannelCode)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(channel => ToDunningChannelItem(channel))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(
|
||||
PlatformAdminActor actor,
|
||||
UpsertPlatformDunningChannelCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext =>
|
||||
{
|
||||
var code = NormalizeCode(command.ChannelCode);
|
||||
var channel = command.ChannelId.HasValue
|
||||
? await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken)
|
||||
: await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken);
|
||||
if (channel is null)
|
||||
{
|
||||
channel = new PlatformDunningNotificationChannel { ChannelCode = code };
|
||||
dbContext.PlatformDunningNotificationChannels.Add(channel);
|
||||
}
|
||||
|
||||
channel.ChannelCode = code;
|
||||
channel.Name = command.Name.Trim();
|
||||
channel.Description = Normalize(command.Description);
|
||||
channel.Enabled = command.Enabled;
|
||||
channel.Provider = command.Provider;
|
||||
channel.WebhookUrl = command.WebhookUrl.Trim();
|
||||
channel.SecretRef = Normalize(command.SecretRef);
|
||||
channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]);
|
||||
channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]);
|
||||
channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20);
|
||||
channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray();
|
||||
channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60);
|
||||
channel.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
AddAudit(dbContext, actor, "platform.dunning_channel.upserted", channel.Id, new
|
||||
{
|
||||
channel.ChannelCode,
|
||||
channel.Name,
|
||||
channel.Enabled,
|
||||
channel.Provider,
|
||||
Webhook = MaskWebhook(channel.WebhookUrl),
|
||||
channel.SecretRef
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDunningChannelItem(channel);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningChannelItem> DisableDunningChannelAsync(
|
||||
PlatformAdminActor actor,
|
||||
DisablePlatformDunningChannelCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning channel disable", async dbContext =>
|
||||
{
|
||||
var channel = await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found");
|
||||
channel.Enabled = false;
|
||||
AddAudit(dbContext, actor, "platform.dunning_channel.disabled", channel.Id, new
|
||||
{
|
||||
channel.ChannelCode,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDunningChannelItem(channel);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningEventList> GetDunningEventsAsync(
|
||||
PlatformAdminActor actor,
|
||||
PlatformAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning event list", async dbContext =>
|
||||
{
|
||||
var events = dbContext.PlatformDunningNotificationEvents.AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status));
|
||||
}
|
||||
|
||||
return new PlatformDunningEventList(await events
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Limit(query.Limit))
|
||||
.Select(item => ToDunningEventItem(item))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningEventItem> GetDunningEventDetailAsync(
|
||||
PlatformAdminActor actor,
|
||||
Guid eventId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning event detail", async dbContext =>
|
||||
{
|
||||
var item = await dbContext.PlatformDunningNotificationEvents.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
|
||||
return ToDunningEventItem(item);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PlatformDunningEventItem> RetryDunningEventAsync(
|
||||
PlatformAdminActor actor,
|
||||
RetryPlatformDunningEventCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken);
|
||||
return await ExecuteSystemAsync("platform dunning event retry", async dbContext =>
|
||||
{
|
||||
var item = await dbContext.PlatformDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken)
|
||||
?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found");
|
||||
item.Status = PlatformDunningNotificationStatus.Pending;
|
||||
item.NextAttemptAt = DateTimeOffset.UtcNow;
|
||||
item.LastError = null;
|
||||
AddAudit(dbContext, actor, "platform.dunning_event.retry_requested", item.Id, new
|
||||
{
|
||||
item.TenantId,
|
||||
item.ChannelId,
|
||||
item.ReminderId,
|
||||
item.InvoiceId,
|
||||
command.Reason
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToDunningEventItem(item);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AssertPlatformPermissionAsync(
|
||||
PlatformAdminActor actor,
|
||||
string permissionCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode))
|
||||
{
|
||||
throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private Task<TResult> ExecuteSystemAsync<TResult>(
|
||||
string reason,
|
||||
Func<TikuDbContext, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
null,
|
||||
reason,
|
||||
async (provider, _) => await operation(provider.GetRequiredService<TikuDbContext>()),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
ActorUserId = actor.UserId,
|
||||
Action = action,
|
||||
TargetType = action.Split('.')[1],
|
||||
TargetId = targetId.ToString("N"),
|
||||
Details = JsonSerializer.SerializeToElement(details)
|
||||
});
|
||||
}
|
||||
|
||||
private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) =>
|
||||
new(
|
||||
tenant.Id,
|
||||
tenant.Slug,
|
||||
tenant.Name,
|
||||
tenant.LegalName,
|
||||
tenant.Status,
|
||||
tenant.Mode,
|
||||
tenant.BillingStatus,
|
||||
subscriptionExpiresAt,
|
||||
domainCount,
|
||||
tenant.CreatedAt,
|
||||
tenant.UpdatedAt);
|
||||
|
||||
private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) =>
|
||||
new(
|
||||
domain.Id,
|
||||
domain.TenantId,
|
||||
domain.Host,
|
||||
domain.DomainType,
|
||||
domain.Status,
|
||||
domain.IsPrimary,
|
||||
domain.VerifiedAt,
|
||||
domain.DnsVerifiedAt,
|
||||
domain.TlsReadyAt,
|
||||
domain.LastCheckedAt,
|
||||
domain.LastFailureReason);
|
||||
|
||||
private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSubscription subscription) =>
|
||||
new(
|
||||
subscription.Id,
|
||||
subscription.TenantId,
|
||||
subscription.PlanCode,
|
||||
subscription.Status,
|
||||
subscription.StartsAt,
|
||||
subscription.ExpiresAt,
|
||||
subscription.BillingCycle,
|
||||
subscription.AmountCents,
|
||||
subscription.Metadata);
|
||||
|
||||
private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) =>
|
||||
new(
|
||||
profile.TenantId,
|
||||
profile.BillingName,
|
||||
profile.TaxId,
|
||||
profile.ContactName,
|
||||
MaskPhone(profile.ContactPhone),
|
||||
profile.ContactEmail,
|
||||
profile.BillingAddress,
|
||||
profile.InvoiceTitle,
|
||||
profile.InvoiceType,
|
||||
profile.BankName,
|
||||
profile.BankAccountMasked,
|
||||
profile.Metadata);
|
||||
|
||||
private static PlatformDunningChannelItem ToDunningChannelItem(PlatformDunningNotificationChannel channel) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.ChannelCode,
|
||||
channel.Name,
|
||||
channel.Description,
|
||||
channel.Enabled,
|
||||
channel.Provider,
|
||||
MaskWebhook(channel.WebhookUrl),
|
||||
channel.SecretRef,
|
||||
channel.ReminderTypes,
|
||||
channel.ReminderChannels,
|
||||
channel.MinReminderLevel,
|
||||
channel.TenantIds,
|
||||
channel.TimeoutSeconds,
|
||||
channel.Metadata,
|
||||
channel.CreatedAt,
|
||||
channel.UpdatedAt);
|
||||
|
||||
private static PlatformDunningEventItem ToDunningEventItem(PlatformDunningNotificationEvent item) =>
|
||||
new(
|
||||
item.Id,
|
||||
item.TenantId,
|
||||
item.ChannelId,
|
||||
item.ReminderId,
|
||||
item.InvoiceId,
|
||||
item.Provider,
|
||||
item.Status,
|
||||
item.Attempts,
|
||||
item.ScheduledAt,
|
||||
item.NextAttemptAt,
|
||||
item.LastAttemptAt,
|
||||
item.SentAt,
|
||||
item.LastError,
|
||||
item.LastHttpCode,
|
||||
item.LastResponseSummary,
|
||||
item.RequestPayload,
|
||||
item.Metadata,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
|
||||
private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection<string> roleCodes) =>
|
||||
new(
|
||||
user.Id,
|
||||
user.Name,
|
||||
MaskPhone(user.Phone),
|
||||
user.Email,
|
||||
user.Status,
|
||||
roleCodes,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt);
|
||||
|
||||
private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200);
|
||||
|
||||
private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant();
|
||||
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value) =>
|
||||
value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone();
|
||||
|
||||
private static string? MaskPhone(string? phone)
|
||||
{
|
||||
var value = Normalize(phone);
|
||||
return value is { Length: >= 7 }
|
||||
? $"{value[..3]}****{value[^4..]}"
|
||||
: value;
|
||||
}
|
||||
|
||||
private static string? MaskBankAccount(string? account)
|
||||
{
|
||||
var value = Normalize(account);
|
||||
return value is { Length: > 8 }
|
||||
? $"****{value[^4..]}"
|
||||
: value;
|
||||
}
|
||||
|
||||
private static string MaskWebhook(string webhookUrl)
|
||||
{
|
||||
var value = Normalize(webhookUrl) ?? string.Empty;
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}";
|
||||
}
|
||||
|
||||
return $"{uri.Scheme}://{uri.Host}/****";
|
||||
}
|
||||
|
||||
private static string[] NormalizeArray(IReadOnlyCollection<string> values, string[] fallback)
|
||||
{
|
||||
var normalized = values
|
||||
.Select(Normalize)
|
||||
.Where(value => value is not null)
|
||||
.Select(value => value!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return normalized.Length == 0 ? fallback : normalized;
|
||||
}
|
||||
|
||||
private static bool ParseEnabledStatus(string status)
|
||||
{
|
||||
return NormalizeCode(status) switch
|
||||
{
|
||||
"enabled" or "active" or "true" => true,
|
||||
"disabled" or "inactive" or "false" => false,
|
||||
_ => throw InvalidStatus(status)
|
||||
};
|
||||
}
|
||||
|
||||
private static TenantStatus ParseTenantStatus(string status) =>
|
||||
Enum.TryParse<TenantStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static TenantDomainStatus ParseDomainStatus(string status) =>
|
||||
Enum.TryParse<TenantDomainStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformSaasPlanStatus ParsePlanStatus(string status) =>
|
||||
Enum.TryParse<PlatformSaasPlanStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) =>
|
||||
Enum.TryParse<PlatformAuditAlertStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformDunningNotificationStatus ParseDunningEventStatus(string status) =>
|
||||
Enum.TryParse<PlatformDunningNotificationStatus>(status, true, out var value) ? value : throw InvalidStatus(status);
|
||||
|
||||
private static PlatformAdminException InvalidStatus(string status) =>
|
||||
new($"Unsupported status '{status}'.", "invalid_status");
|
||||
}
|
||||
@@ -15,6 +15,11 @@ using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus;
|
||||
using OrderStatus = Tiku.Domain.Commerce.OrderStatus;
|
||||
using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus;
|
||||
using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus;
|
||||
using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus;
|
||||
|
||||
namespace Tiku.Infrastructure.TenantAdmin;
|
||||
|
||||
@@ -25,6 +30,90 @@ public sealed class TenantAdminDirectService(
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IAuthSessionStore sessionStore) : ITenantAdminDirectService
|
||||
{
|
||||
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var classIds = scope.ClassIds.ToArray();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero);
|
||||
var scopedClasses = dbContext.TenantClasses.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.CreatedBy == actor.UserId,
|
||||
item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)));
|
||||
var scopedStudents = dbContext.StudentProfiles.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
var scopedOrders = dbContext.Orders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
|
||||
var studentCount = await scopedStudents.CountAsync(cancellationToken);
|
||||
var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken);
|
||||
var staffCount = await dbContext.TenantMemberships.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Status == MembershipStatus.Active &&
|
||||
item.Role != TenantRole.Student,
|
||||
cancellationToken);
|
||||
var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.FinishedAt == null &&
|
||||
(!item.ExpiresAt.HasValue || item.ExpiresAt > now),
|
||||
cancellationToken);
|
||||
var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking()
|
||||
.CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken);
|
||||
var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
(item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress),
|
||||
cancellationToken);
|
||||
var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking()
|
||||
.CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken);
|
||||
var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken);
|
||||
var revenueCents = await scopedOrders
|
||||
.Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded)
|
||||
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
|
||||
var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
(item.Status == CommerceRefundStatus.Requested ||
|
||||
item.Status == CommerceRefundStatus.Approved ||
|
||||
item.Status == CommerceRefundStatus.Processing),
|
||||
cancellationToken);
|
||||
var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Status != ReconciliationIssueStatus.Resolved &&
|
||||
item.Status != ReconciliationIssueStatus.Ignored,
|
||||
cancellationToken);
|
||||
|
||||
return new TenantAdminOverviewItem(
|
||||
studentCount,
|
||||
classCount,
|
||||
staffCount,
|
||||
activePracticeCount,
|
||||
todayPracticeCount,
|
||||
pendingFollowupCount,
|
||||
unreadNotificationCount,
|
||||
paidOrderCount,
|
||||
revenueCents,
|
||||
pendingRefundCount,
|
||||
openReconciliationIssueCount,
|
||||
now);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminClassList> GetClassesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminClassFilter filter,
|
||||
@@ -494,6 +583,289 @@ public sealed class TenantAdminDirectService(
|
||||
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
|
||||
}
|
||||
|
||||
public async Task<TenantAdminStudentImportPreview> PreviewStudentImportAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
|
||||
return new TenantAdminStudentImportPreview(
|
||||
command.Rows.Count,
|
||||
items.Count(item => item.Valid),
|
||||
items.Count(item => !item.Valid),
|
||||
items);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminStudentImportResult> ImportStudentsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken);
|
||||
var invalidItems = previewItems.Where(item => !item.Valid).ToArray();
|
||||
var createdOrUpdated = 0;
|
||||
var classAssigned = 0;
|
||||
var rowNo = 0;
|
||||
foreach (var row in command.Rows)
|
||||
{
|
||||
rowNo++;
|
||||
if (invalidItems.Any(item => item.RowNo == rowNo))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var user = await ResolveUserAsync(row.User, "student", cancellationToken);
|
||||
if (row.RawProfile.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
user.RawProfile = row.RawProfile.Clone();
|
||||
}
|
||||
|
||||
await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken);
|
||||
await EnsureStudentProfileAsync(
|
||||
actor.TenantId,
|
||||
user.Id,
|
||||
row.RegionId,
|
||||
null,
|
||||
null,
|
||||
Normalize(row.AvatarPreset),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Object(),
|
||||
cancellationToken);
|
||||
createdOrUpdated++;
|
||||
|
||||
if (row.ClassId.HasValue)
|
||||
{
|
||||
await UpsertClassMemberCoreAsync(
|
||||
actor,
|
||||
row.ClassId.Value,
|
||||
user.Id,
|
||||
TenantClassMemberType.Student,
|
||||
TenantClassMemberStatus.Active,
|
||||
JsonSerializer.SerializeToElement(new { source = "student_import" }),
|
||||
cancellationToken);
|
||||
classAssigned++;
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminBulkOperationResult> BulkAssignClassAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminBulkAssignClassCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
await AssertClassAsync(actor, scope, command.ClassId, cancellationToken);
|
||||
var failed = new List<Guid>();
|
||||
var succeeded = 0;
|
||||
foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
|
||||
{
|
||||
try
|
||||
{
|
||||
await AssertStudentAsync(actor, scope, userId, cancellationToken);
|
||||
await UpsertClassMemberCoreAsync(
|
||||
actor,
|
||||
command.ClassId,
|
||||
userId,
|
||||
TenantClassMemberType.Student,
|
||||
TenantClassMemberStatus.Active,
|
||||
JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }),
|
||||
cancellationToken);
|
||||
succeeded++;
|
||||
}
|
||||
catch (TenantAdminDirectException)
|
||||
{
|
||||
failed.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
|
||||
}
|
||||
|
||||
public async Task<TenantAdminBulkOperationResult> BulkUpdateStudentStatusAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminBulkStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var failed = new List<Guid>();
|
||||
var succeeded = 0;
|
||||
foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct())
|
||||
{
|
||||
try
|
||||
{
|
||||
await UpdateStudentStatusAsync(
|
||||
actor,
|
||||
new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason),
|
||||
cancellationToken);
|
||||
succeeded++;
|
||||
}
|
||||
catch (TenantAdminDirectException)
|
||||
{
|
||||
failed.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<TenantSupervisionRuleItem>> GetSupervisionRulesAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireDataScopeAsync(actor, cancellationToken);
|
||||
return new CatalogList<TenantSupervisionRuleItem>(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<TenantSupervisionRuleItem>> UpsertSupervisionRuleAsync(
|
||||
TenantAdminActor actor,
|
||||
UpsertTenantSupervisionRuleCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var code = NormalizeCode(command.Code);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
||||
var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList();
|
||||
var item = new TenantSupervisionRuleItem(
|
||||
code,
|
||||
command.Title.Trim(),
|
||||
command.Enabled,
|
||||
command.DaysWithoutCheckIn,
|
||||
command.MaxQuestionsAnsweredToday,
|
||||
Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(),
|
||||
Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(),
|
||||
JsonObjectOrDefault(command.Metadata));
|
||||
rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal));
|
||||
rules.Add(item);
|
||||
await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken);
|
||||
await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<TenantSupervisionRuleItem>(item);
|
||||
}
|
||||
|
||||
public async Task<TenantSupervisionPreview> PreviewSupervisionAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<TenantSupervisionGenerateResult> GenerateSupervisionFollowupsAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantSupervisionGenerateCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken);
|
||||
var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken);
|
||||
if (command.UserIds is { Count: > 0 })
|
||||
{
|
||||
var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet();
|
||||
riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray();
|
||||
}
|
||||
|
||||
var created = 0;
|
||||
foreach (var student in riskStudents)
|
||||
{
|
||||
if (await dbContext.TenantStudentFollowups.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.StudentUserId == student.UserId &&
|
||||
item.Status != StudentFollowupStatus.Done &&
|
||||
item.FollowupType == StudentFollowupType.Risk,
|
||||
cancellationToken))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
StudentUserId = student.UserId,
|
||||
AssignedToUserId = command.AssignedToUserId,
|
||||
Title = "学习风险督导",
|
||||
Description = string.Join(";", student.Reasons),
|
||||
FollowupType = StudentFollowupType.Risk,
|
||||
Priority = StudentFollowupPriority.High,
|
||||
Status = StudentFollowupStatus.Open,
|
||||
DueAt = command.DueAt,
|
||||
CreatedBy = actor.UserId,
|
||||
UpdatedBy = actor.UserId,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes })
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TenantSupervisionGenerateResult(created);
|
||||
}
|
||||
|
||||
public async Task<TenantFollowupReport> GetFollowupReportAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new TenantFollowupReport(
|
||||
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken),
|
||||
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken),
|
||||
await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken),
|
||||
await dbContext.TenantStudentFollowups.CountAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.DueAt.HasValue &&
|
||||
item.DueAt < now &&
|
||||
item.Status != StudentFollowupStatus.Done &&
|
||||
item.Status != StudentFollowupStatus.Cancelled,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<TenantFeedbackReport> GetFeedbackReportAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireDataScopeAsync(actor, cancellationToken);
|
||||
return new TenantFeedbackReport(
|
||||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken),
|
||||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken),
|
||||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken),
|
||||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken),
|
||||
await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<TenantPointRiskReport> GetPointRiskReportAsync(
|
||||
TenantAdminActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var negativeScoreUsers = await dbContext.Users
|
||||
.Where(user => user.Score < 0 &&
|
||||
dbContext.TenantMemberships.Any(member =>
|
||||
member.TenantId == actor.TenantId &&
|
||||
member.UserId == user.Id &&
|
||||
member.Status == MembershipStatus.Active))
|
||||
.CountAsync(cancellationToken);
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-7);
|
||||
var highClaimUsers = await dbContext.PointActivityClaims
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since)
|
||||
.GroupBy(item => item.UserId)
|
||||
.Where(group => group.Sum(item => item.Points) >= 1000)
|
||||
.CountAsync(cancellationToken);
|
||||
var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled,
|
||||
cancellationToken);
|
||||
return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<TenantAdminStudentNoteItem>> GetStudentNotesAsync(
|
||||
TenantAdminActor actor,
|
||||
TenantAdminStudentActivityFilter filter,
|
||||
@@ -1669,6 +2041,197 @@ public sealed class TenantAdminDirectService(
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TenantAdminStudentImportPreviewItem>> BuildStudentImportPreviewAsync(
|
||||
TenantAdminActor actor,
|
||||
CurrentDataScope scope,
|
||||
TenantAdminStudentImportCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var items = new List<TenantAdminStudentImportPreviewItem>();
|
||||
var rowNo = 0;
|
||||
foreach (var row in command.Rows.Take(1000))
|
||||
{
|
||||
rowNo++;
|
||||
string? reason = null;
|
||||
var phone = Normalize(row.User.Phone);
|
||||
var email = Normalize(row.User.Email);
|
||||
var name = Normalize(row.User.Name);
|
||||
if (row.User.UserId is null && phone is null && email is null && name is null)
|
||||
{
|
||||
reason = "user_required";
|
||||
}
|
||||
else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId))
|
||||
{
|
||||
reason = "data_scope_denied";
|
||||
}
|
||||
else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken))
|
||||
{
|
||||
reason = "region_not_found";
|
||||
}
|
||||
else if (row.ClassId.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AssertClassAsync(actor, scope, row.ClassId, cancellationToken);
|
||||
}
|
||||
catch (TenantAdminDirectException exception)
|
||||
{
|
||||
reason = exception.Code;
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new TenantAdminStudentImportPreviewItem(
|
||||
rowNo,
|
||||
reason is null,
|
||||
reason,
|
||||
phone,
|
||||
email,
|
||||
name,
|
||||
row.RegionId,
|
||||
row.ClassId));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task<TenantClassMember> UpsertClassMemberCoreAsync(
|
||||
TenantAdminActor actor,
|
||||
Guid classId,
|
||||
Guid userId,
|
||||
TenantClassMemberType memberType,
|
||||
TenantClassMemberStatus status,
|
||||
JsonElement metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member =>
|
||||
member.TenantId == actor.TenantId &&
|
||||
member.ClassId == classId &&
|
||||
member.UserId == userId &&
|
||||
member.MemberType == memberType,
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new TenantClassMember
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
ClassId = classId,
|
||||
UserId = userId,
|
||||
MemberType = memberType,
|
||||
JoinedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
dbContext.TenantClassMembers.Add(item);
|
||||
}
|
||||
|
||||
item.Status = status;
|
||||
item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null;
|
||||
item.Metadata = JsonObjectOrDefault(metadata);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TenantSupervisionRuleItem>> GetSupervisionRulesCoreAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (settings is null ||
|
||||
settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object ||
|
||||
!settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) ||
|
||||
rulesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TenantSupervisionRuleItem[]>(rulesElement.GetRawText()) ?? [];
|
||||
}
|
||||
|
||||
private async Task SaveSupervisionRulesCoreAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<TenantSupervisionRuleItem> rules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (settings is null)
|
||||
{
|
||||
settings = new TenantSettings { TenantId = tenantId };
|
||||
dbContext.TenantSettings.Add(settings);
|
||||
}
|
||||
|
||||
var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object
|
||||
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.AdminFeatureFlags.GetRawText()) ?? []
|
||||
: [];
|
||||
existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules);
|
||||
settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<TenantSupervisionRiskStudentItem>> BuildSupervisionRiskStudentsAsync(
|
||||
TenantAdminActor actor,
|
||||
CurrentDataScope scope,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken))
|
||||
.Where(rule => rule.Enabled)
|
||||
.ToArray();
|
||||
if (rules.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var students = await dbContext.StudentProfiles.AsNoTracking()
|
||||
.Where(profile => profile.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
profile => profile.UserId == actor.UserId,
|
||||
profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value))
|
||||
.Select(profile => new
|
||||
{
|
||||
Profile = profile,
|
||||
User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault()
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var result = new List<TenantSupervisionRiskStudentItem>();
|
||||
foreach (var row in students)
|
||||
{
|
||||
var hitRules = new List<string>();
|
||||
var reasons = new List<string>();
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.DaysWithoutCheckIn.HasValue)
|
||||
{
|
||||
var days = row.Profile.LastCheckInDate.HasValue
|
||||
? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber
|
||||
: int.MaxValue;
|
||||
if (days >= rule.DaysWithoutCheckIn.Value)
|
||||
{
|
||||
hitRules.Add(rule.Code);
|
||||
reasons.Add($"{rule.Title}: {days} days without check-in");
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.MaxQuestionsAnsweredToday.HasValue &&
|
||||
row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value)
|
||||
{
|
||||
hitRules.Add(rule.Code);
|
||||
reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
if (hitRules.Count > 0)
|
||||
{
|
||||
result.Add(new TenantSupervisionRiskStudentItem(
|
||||
row.Profile.UserId,
|
||||
row.User?.Name,
|
||||
MaskPhone(row.User?.Phone),
|
||||
row.Profile.RegionId,
|
||||
hitRules.Distinct(StringComparer.Ordinal).ToArray(),
|
||||
reasons.Distinct(StringComparer.Ordinal).ToArray()));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(
|
||||
@@ -2261,6 +2824,19 @@ public sealed class TenantAdminDirectService(
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string value)
|
||||
{
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? MaskPhone(string? phone)
|
||||
{
|
||||
var value = Normalize(phone);
|
||||
return value is { Length: >= 7 }
|
||||
? $"{value[..3]}****{value[^4..]}"
|
||||
: value;
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
|
||||
|
||||
@@ -34,6 +34,24 @@ internal static class AuthenticationTestClientExtensions
|
||||
return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier);
|
||||
}
|
||||
|
||||
public static async Task<TestAuthenticationTokens> LoginAsPlatformAsync(
|
||||
this HttpClient client,
|
||||
string identifier,
|
||||
string password = PasswordTestUserExtensions.TestPassword)
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Platform,
|
||||
Identifier = identifier,
|
||||
Password = password
|
||||
});
|
||||
|
||||
return await client.CompletePlatformAuthenticationAsync(response, identifier);
|
||||
}
|
||||
|
||||
public static async Task<TestAuthenticationTokens> CompleteTenantAuthenticationAsync(
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
@@ -96,6 +114,67 @@ internal static class AuthenticationTestClientExtensions
|
||||
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
}
|
||||
|
||||
private static async Task<TestAuthenticationTokens> CompletePlatformAuthenticationAsync(
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
string authenticatorCacheKey)
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
using var authentication = await ReadSuccessfulJsonAsync(response);
|
||||
var root = authentication.RootElement;
|
||||
var status = root.GetProperty("status").GetString();
|
||||
|
||||
if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ReadTokens(root.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
var challengeToken = root.GetProperty("challengeToken").GetString()
|
||||
?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token.");
|
||||
var keyId = $"platform:{authenticatorCacheKey}";
|
||||
|
||||
if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var setupResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/setup",
|
||||
new MfaChallengeDto { ChallengeToken = challengeToken });
|
||||
using var setup = await ReadSuccessfulJsonAsync(setupResponse);
|
||||
var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString()
|
||||
?? throw new InvalidOperationException("MFA setup did not return a shared key.");
|
||||
AuthenticatorKeys[keyId] = sharedKey;
|
||||
|
||||
var confirmResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/confirm",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(sharedKey)
|
||||
});
|
||||
using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse);
|
||||
return ReadTokens(
|
||||
confirmation.RootElement
|
||||
.GetProperty("authentication")
|
||||
.GetProperty("user")
|
||||
.GetProperty("tokens"));
|
||||
}
|
||||
|
||||
if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) &&
|
||||
AuthenticatorKeys.TryGetValue(keyId, out var existingKey))
|
||||
{
|
||||
var verifyResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/mfa/totp/verify",
|
||||
new MfaChallengeDto
|
||||
{
|
||||
ChallengeToken = challengeToken,
|
||||
Code = GenerateTotp(existingKey)
|
||||
});
|
||||
using var verification = await ReadSuccessfulJsonAsync(verifyResponse);
|
||||
return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens"));
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported test authentication status '{status}'.");
|
||||
}
|
||||
|
||||
public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken);
|
||||
|
||||
310
Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs
Normal file
310
Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs
Normal file
@@ -0,0 +1,310 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class PlatformAdminEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Platform_admin_can_manage_tenant_subscription_domain_recheck_and_audit()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
});
|
||||
var platform = await SeedPlatformAdminAsync(factory);
|
||||
var tenantId = Guid.NewGuid();
|
||||
var domainId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = "tenant-six-a",
|
||||
Name = "Tenant Six A",
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.Trial
|
||||
},
|
||||
new TenantDomain
|
||||
{
|
||||
Id = domainId,
|
||||
TenantId = tenantId,
|
||||
Host = "six-a.example.test",
|
||||
Status = TenantDomainStatus.Active,
|
||||
IsPrimary = true,
|
||||
VerificationToken = "verify-six-a",
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
DnsVerifiedAt = DateTimeOffset.UtcNow,
|
||||
TlsReadyAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new PlatformSaasPlan
|
||||
{
|
||||
Code = "standard",
|
||||
Name = "Standard",
|
||||
BaseAmountCents = 99900,
|
||||
Status = PlatformSaasPlanStatus.Active
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
|
||||
var overview = await client.GetAsync("/api/platform-admin/overview");
|
||||
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
|
||||
var subscription = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/subscriptions",
|
||||
new UpsertPlatformSubscriptionDto
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanCode = "standard",
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
AmountCents = 99900
|
||||
});
|
||||
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
|
||||
var suspended = await client.PatchAsJsonAsync(
|
||||
"/api/platform-admin/tenants/status",
|
||||
new UpdatePlatformTenantStatusDto
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Status = TenantStatus.Suspended,
|
||||
BillingStatus = BillingStatus.PastDue,
|
||||
Reason = "integration test suspension"
|
||||
});
|
||||
|
||||
using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
|
||||
runtimeRequest.Headers.Host = "six-a.example.test";
|
||||
var runtimeAfterSuspend = await client.SendAsync(runtimeRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify platform admin side effects");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.BackgroundJobs.AnyAsync(job =>
|
||||
job.TenantId == tenantId &&
|
||||
job.JobType == "tenant_domain_recheck"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.tenant.status_changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_token_cannot_access_platform_admin_endpoints()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13866660000";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Tenant Realm"
|
||||
},
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Phone = phone,
|
||||
Name = "Tenant Admin"
|
||||
}.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.TenantAdmin,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone));
|
||||
|
||||
var response = await client.GetAsync("/api/platform-admin/overview");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_admin_can_manage_dunning_channels_and_retry_events()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
|
||||
{
|
||||
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
|
||||
});
|
||||
var platform = await SeedPlatformAdminAsync(factory);
|
||||
var tenantId = Guid.NewGuid();
|
||||
var invoiceId = Guid.NewGuid();
|
||||
var reminderId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = "tenant-dunning-a",
|
||||
Name = "Tenant Dunning A",
|
||||
Status = TenantStatus.Active,
|
||||
BillingStatus = BillingStatus.PastDue
|
||||
},
|
||||
new TenantInvoice
|
||||
{
|
||||
Id = invoiceId,
|
||||
TenantId = tenantId,
|
||||
InvoiceNo = "INV-DUNNING-1",
|
||||
Status = TenantInvoiceStatus.Overdue,
|
||||
TotalCents = 10_000,
|
||||
BalanceCents = 10_000
|
||||
},
|
||||
new TenantInvoiceReminder
|
||||
{
|
||||
Id = reminderId,
|
||||
TenantId = tenantId,
|
||||
InvoiceId = invoiceId,
|
||||
ReminderType = TenantInvoiceReminderType.Overdue,
|
||||
Channel = TenantInvoiceReminderChannel.Wechat,
|
||||
Status = TenantInvoiceReminderStatus.Failed,
|
||||
ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
|
||||
BalanceCentsSnapshot = 10_000
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email));
|
||||
|
||||
var upsertResponse = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/dunning-notification-channels",
|
||||
new UpsertPlatformDunningChannelDto
|
||||
{
|
||||
ChannelCode = "wecom-overdue",
|
||||
Name = "企业微信逾期提醒",
|
||||
Provider = PlatformDunningProvider.Wecom,
|
||||
WebhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret-key",
|
||||
SecretRef = "platform_secrets:dunning:wecom:default",
|
||||
ReminderTypes = ["overdue"],
|
||||
ReminderChannels = ["wechat"],
|
||||
MinReminderLevel = 2,
|
||||
TenantIds = [tenantId]
|
||||
});
|
||||
var upsertBody = await upsertResponse.Content.ReadAsStringAsync();
|
||||
var channelJson = JsonDocument.Parse(upsertBody);
|
||||
var channelId = channelJson.RootElement.GetProperty("id").GetGuid();
|
||||
await factory.SeedAsync(new PlatformDunningNotificationEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ChannelId = channelId,
|
||||
ReminderId = reminderId,
|
||||
InvoiceId = invoiceId,
|
||||
Provider = PlatformDunningProvider.Wecom,
|
||||
Status = PlatformDunningNotificationStatus.Failed,
|
||||
Attempts = 2,
|
||||
LastError = "timeout",
|
||||
LastHttpCode = 500,
|
||||
LastResponseSummary = "server error",
|
||||
RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 })
|
||||
});
|
||||
|
||||
var channelsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-channels?search=wecom");
|
||||
var eventsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-events?status=failed");
|
||||
var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync());
|
||||
var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid();
|
||||
var detailResponse = await client.GetAsync($"/api/platform-admin/dunning-notification-events/detail?eventId={eventId}");
|
||||
var retryResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/dunning-notification-events/retry",
|
||||
new RetryPlatformDunningEventDto
|
||||
{
|
||||
EventId = eventId,
|
||||
Reason = "manual retry"
|
||||
});
|
||||
var disableResponse = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/dunning-notification-channels/disable",
|
||||
new DisablePlatformDunningChannelDto
|
||||
{
|
||||
ChannelId = channelId,
|
||||
Reason = "disable test"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode);
|
||||
Assert.DoesNotContain("secret-key", upsertBody, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("https://qyapi.weixin.qq.com/****", upsertBody, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(HttpStatusCode.OK, channelsResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode);
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify platform dunning side effects");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedEvent = await dbContext.PlatformDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId);
|
||||
var storedChannel = await dbContext.PlatformDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId);
|
||||
Assert.Equal(PlatformDunningNotificationStatus.Pending, storedEvent.Status);
|
||||
Assert.Null(storedEvent.LastError);
|
||||
Assert.False(storedChannel.Enabled);
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.dunning_event.retry_requested"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.dunning_channel.disabled"));
|
||||
}
|
||||
|
||||
private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var email = $"platform-{Guid.NewGuid():N}@example.test";
|
||||
var permissions = BackendPermissions.Platform.Select(code => new BackendPermission
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
Module = code.Split(':')[1],
|
||||
IsSystem = true
|
||||
}).Cast<object>().ToList();
|
||||
await factory.SeedAsync(
|
||||
[
|
||||
..permissions,
|
||||
new User
|
||||
{
|
||||
Id = userId,
|
||||
Email = email,
|
||||
NormalizedEmail = email.ToUpperInvariant(),
|
||||
UserName = email,
|
||||
NormalizedUserName = email.ToUpperInvariant(),
|
||||
Name = "Platform Admin",
|
||||
PrimaryRole = "platform_admin",
|
||||
RawProfile = JsonDefaults.Object()
|
||||
}.WithTestPassword(),
|
||||
new PlatformBackendRole
|
||||
{
|
||||
Id = roleId,
|
||||
Code = "platform_super_admin",
|
||||
Name = "Platform Super Admin",
|
||||
Status = BackendRoleStatus.Active,
|
||||
IsSystem = true
|
||||
},
|
||||
..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission
|
||||
{
|
||||
RoleId = roleId,
|
||||
PermissionCode = code
|
||||
}),
|
||||
new PlatformBackendUserRole
|
||||
{
|
||||
UserId = userId,
|
||||
RoleId = roleId
|
||||
}
|
||||
]);
|
||||
return (userId, email);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
@@ -17,6 +18,177 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class TenantAdminDirectEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_view_operational_overview()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var studentId = Guid.NewGuid();
|
||||
var classId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new User { Id = studentId, Phone = "13900009901", Name = "概览学生" },
|
||||
new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
|
||||
new StudentProfile { TenantId = seed.TenantId, UserId = studentId },
|
||||
new TenantClass { Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active },
|
||||
new TenantStudentFollowup
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
StudentUserId = studentId,
|
||||
Title = "待跟进",
|
||||
Status = StudentFollowupStatus.Open
|
||||
},
|
||||
new UserNotification
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
UserId = studentId,
|
||||
NotificationType = "admin",
|
||||
Title = "通知",
|
||||
Message = "概览通知",
|
||||
Status = NotificationStatus.Unread
|
||||
},
|
||||
new Order
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
UserId = studentId,
|
||||
OrderNo = "OVERVIEW-ORDER",
|
||||
Status = OrderStatus.Paid,
|
||||
AmountCents = 2_000,
|
||||
PaidAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var response = await client.GetAsync("/api/tenant-admin/overview");
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(1, body.RootElement.GetProperty("studentCount").GetInt32());
|
||||
Assert.Equal(1, body.RootElement.GetProperty("classCount").GetInt32());
|
||||
Assert.Equal(1, body.RootElement.GetProperty("pendingFollowupCount").GetInt32());
|
||||
Assert.Equal(1, body.RootElement.GetProperty("unreadNotificationCount").GetInt32());
|
||||
Assert.Equal(1, body.RootElement.GetProperty("paidOrderCount").GetInt32());
|
||||
Assert.Equal(2_000, body.RootElement.GetProperty("revenueCents").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_run_student_bulk_operations_supervision_and_reports()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedAdminAsync(factory);
|
||||
var regionId = Guid.NewGuid();
|
||||
var classId = Guid.NewGuid();
|
||||
var pointTaskId = Guid.NewGuid();
|
||||
var pointItemId = Guid.NewGuid();
|
||||
var existingStudentId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Region { Id = regionId, TenantId = seed.TenantId, Name = "批量区域" },
|
||||
new TenantClass { Id = classId, TenantId = seed.TenantId, RegionId = regionId, Name = "批量班级", Status = TenantRecordStatus.Active },
|
||||
new User { Id = existingStudentId, Phone = "13900008888", Name = "已有学生", Score = -10 },
|
||||
new TenantMembership { TenantId = seed.TenantId, UserId = existingStudentId, Role = TenantRole.Student, Status = MembershipStatus.Active },
|
||||
new StudentProfile
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
UserId = existingStudentId,
|
||||
RegionId = regionId,
|
||||
LastCheckInDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10)),
|
||||
QuestionsAnsweredToday = 0
|
||||
},
|
||||
new Report { TenantId = seed.TenantId, UserId = existingStudentId, Status = ReportStatus.Pending, Type = ReportType.Suggestion },
|
||||
new PointActivityTask { Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 },
|
||||
new PointActivityClaim
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TaskId = pointTaskId,
|
||||
UserId = existingStudentId,
|
||||
TaskKey = "bulk-risk",
|
||||
Points = 1000,
|
||||
Status = PointActivityClaimStatus.Claimed
|
||||
},
|
||||
new PointExchangeItem { Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 },
|
||||
new PointExchangeOrder
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
ItemId = pointItemId,
|
||||
UserId = existingStudentId,
|
||||
OrderNo = "POINT-RISK-1",
|
||||
ItemName = "风险兑换",
|
||||
Status = PointExchangeOrderStatus.Cancelled,
|
||||
PointsCost = 10
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
var importRequest = new TenantAdminStudentImportDto
|
||||
{
|
||||
Rows =
|
||||
[
|
||||
new TenantAdminStudentImportRowDto
|
||||
{
|
||||
User = new TenantAdminUserLookupDto { Phone = "13900007777", Name = "批量学生" },
|
||||
RegionId = regionId,
|
||||
ClassId = classId
|
||||
},
|
||||
new TenantAdminStudentImportRowDto()
|
||||
]
|
||||
};
|
||||
|
||||
var previewResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import/preview", importRequest);
|
||||
var importResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import", importRequest);
|
||||
var importJson = await ReadJsonAsync(importResponse);
|
||||
var importedUserId = await GetUserIdByPhoneAsync(factory, "13900007777");
|
||||
var assignResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-admin/students/bulk-assign-class",
|
||||
new TenantAdminBulkAssignClassDto { ClassId = classId, UserIds = [existingStudentId, importedUserId] });
|
||||
var statusResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-admin/students/bulk-status",
|
||||
new TenantAdminBulkStatusDto { UserIds = [importedUserId], Status = "disabled", Reason = "batch test" });
|
||||
var ruleResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-admin/students/supervision/rules",
|
||||
new UpsertTenantSupervisionRuleDto
|
||||
{
|
||||
Code = "inactive",
|
||||
Title = "长时间未学习",
|
||||
DaysWithoutCheckIn = 3,
|
||||
MaxQuestionsAnsweredToday = 0
|
||||
});
|
||||
var rulesResponse = await client.GetAsync("/api/tenant-admin/students/supervision/rules");
|
||||
var previewRiskResponse = await client.GetAsync("/api/tenant-admin/students/supervision/preview");
|
||||
var generateResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-admin/students/supervision/generate",
|
||||
new TenantSupervisionGenerateDto { UserIds = [existingStudentId], AssignedToUserId = seed.UserId });
|
||||
var followupReportResponse = await client.GetAsync("/api/tenant-admin/student-followups/report");
|
||||
var feedbackReportResponse = await client.GetAsync("/api/tenant-admin/feedbacks/report");
|
||||
var pointRiskReportResponse = await client.GetAsync("/api/tenant-admin/points/risk-report");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
|
||||
Assert.Equal(1, importJson.RootElement.GetProperty("createdOrUpdatedCount").GetInt32());
|
||||
Assert.Equal(1, importJson.RootElement.GetProperty("invalidItems").GetArrayLength());
|
||||
Assert.Equal(HttpStatusCode.OK, assignResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, ruleResponse.StatusCode);
|
||||
Assert.Contains("inactive", await rulesResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(existingStudentId.ToString(), await previewRiskResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(HttpStatusCode.OK, generateResponse.StatusCode);
|
||||
Assert.Contains("openCount", await followupReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("pendingCount", await feedbackReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("negativeScoreUserCount", await pointRiskReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.TenantClassMembers.AnyAsync(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.ClassId == classId &&
|
||||
item.UserId == importedUserId));
|
||||
Assert.True(await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.UserId == importedUserId &&
|
||||
item.Status == MembershipStatus.Disabled));
|
||||
Assert.True(await dbContext.TenantStudentFollowups.AnyAsync(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.StudentUserId == existingStudentId &&
|
||||
item.FollowupType == StudentFollowupType.Risk));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_admin_can_manage_classes_members_and_students()
|
||||
{
|
||||
@@ -404,6 +576,16 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static async Task<Guid> GetUserIdByPhoneAsync(ApiTestFactory factory, string phone)
|
||||
{
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
return await dbContext.Users
|
||||
.Where(user => user.Phone == phone)
|
||||
.Select(user => user.Id)
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -17,6 +21,11 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class TenantCommerceEndpointTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Non_admin_cannot_access_tenant_commerce_operations()
|
||||
{
|
||||
@@ -152,6 +161,89 @@ public sealed class TenantCommerceEndpointTests
|
||||
item.SourceType == "activation_code");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Commerce_reconciliation_worker_does_not_succeed_without_active_provider_config()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Worker Provider Required"
|
||||
});
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var job = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"commerce_reconciliation",
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
provider = PaymentProviders.WechatPay,
|
||||
billDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
|
||||
billType = ReconciliationBillType.Combined.ToString()
|
||||
})));
|
||||
|
||||
var processed = await jobService.ProcessPendingAsync("integration-worker", 10);
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id);
|
||||
Assert.Equal(1, processed);
|
||||
Assert.Equal(BackgroundJobStatus.Pending, storedJob.Status);
|
||||
Assert.Equal(1, storedJob.RetryCount);
|
||||
Assert.Contains("Active payment provider", storedJob.LastError, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(await dbContext.CommerceReconciliationBatches.AnyAsync(item => item.TenantId == tenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_processes_content_export_and_statistics_aggregation()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Worker Export Tenant"
|
||||
},
|
||||
new User { Id = userId, Phone = "13900006666", Name = "Worker Student" },
|
||||
new StudentProfile { TenantId = tenantId, UserId = userId },
|
||||
new PracticeSession { TenantId = tenantId, UserId = userId, QuestionCount = 1 },
|
||||
new Order
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
OrderNo = "WORKER-STATS-ORDER",
|
||||
Status = OrderStatus.Paid,
|
||||
AmountCents = 800,
|
||||
PaidAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var exportJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"content_export",
|
||||
JsonSerializer.SerializeToElement(new { exportType = "students" })));
|
||||
var statsJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"statistics_aggregation",
|
||||
JsonSerializer.SerializeToElement(new { scope = "tenant" })));
|
||||
|
||||
var processed = await jobService.ProcessPendingAsync("integration-worker", 10);
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedExportJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == exportJob.Id);
|
||||
var storedStatsJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == statsJob.Id);
|
||||
Assert.Equal(2, processed);
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, storedExportJob.Status);
|
||||
Assert.NotNull(storedExportJob.OutputAssetId);
|
||||
Assert.True(await dbContext.ContentAssets.AnyAsync(item => item.Id == storedExportJob.OutputAssetId));
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, storedStatsJob.Status);
|
||||
Assert.Equal(800, storedStatsJob.Result.GetProperty("revenueCents").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_can_manage_points_and_coupons()
|
||||
{
|
||||
@@ -252,6 +344,266 @@ public sealed class TenantCommerceEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, allowedRefund.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_can_preview_import_and_query_reconciliation_operations()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
var rows = JsonSerializer.SerializeToElement(new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
transactionType = "payment",
|
||||
providerTradeNo = "wx-trade-1",
|
||||
orderNo = "ORDER-1",
|
||||
amountCents = 1000,
|
||||
providerStatus = "paid",
|
||||
localStatus = "paid",
|
||||
matchStatus = "matched"
|
||||
},
|
||||
new
|
||||
{
|
||||
transactionType = "refund",
|
||||
providerTradeNo = "wx-trade-2",
|
||||
providerRefundNo = "wx-refund-2",
|
||||
orderNo = "ORDER-2",
|
||||
refundNo = "REFUND-2",
|
||||
refundAmountCents = 300,
|
||||
providerStatus = "succeeded",
|
||||
localStatus = "processing",
|
||||
matchStatus = "status_mismatch",
|
||||
issueCode = "refund_status_mismatch"
|
||||
}
|
||||
});
|
||||
var request = new PreviewReconciliationImportDto
|
||||
{
|
||||
Provider = PaymentProviders.WechatPay,
|
||||
BillDate = DateOnly.FromDateTime(DateTime.UtcNow.Date),
|
||||
BillType = ReconciliationBillType.Combined,
|
||||
SourceName = "wechat-bill.csv",
|
||||
Rows = rows
|
||||
};
|
||||
|
||||
var previewResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/preview", request);
|
||||
var preview = await previewResponse.Content.ReadFromJsonAsync<ReconciliationImportPreview>(JsonOptions);
|
||||
var importResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/import", request);
|
||||
var batch = await importResponse.Content.ReadFromJsonAsync<CommerceReconciliationBatch>(JsonOptions);
|
||||
var itemsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/items?batchId={batch!.Id}");
|
||||
var items = await itemsResponse.Content.ReadFromJsonAsync<TenantReconciliationItemList>(JsonOptions);
|
||||
var issuesResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/issues");
|
||||
var issues = await issuesResponse.Content.ReadFromJsonAsync<TenantReconciliationIssueList>(JsonOptions);
|
||||
var issueEventsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}");
|
||||
var anomalies = await client.GetFromJsonAsync<TenantCommerceAnomalySummary>("/api/tenant-commerce/reconciliation/anomalies", JsonOptions);
|
||||
var accountResponse = await client.PutAsJsonAsync(
|
||||
"/api/tenant-commerce/payment-accounts",
|
||||
new UpsertPaymentAccountDto
|
||||
{
|
||||
Provider = PaymentProviders.WechatPay,
|
||||
Status = TenantExternalProviderStatus.Active,
|
||||
ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch" })
|
||||
});
|
||||
var jobResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-commerce/reconciliation/provider-bills/request",
|
||||
new RequestProviderBillJobDto
|
||||
{
|
||||
Provider = PaymentProviders.WechatPay,
|
||||
BillDate = request.BillDate,
|
||||
BillType = ReconciliationBillType.Combined
|
||||
});
|
||||
var jobsResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/provider-bills/jobs?limit=5");
|
||||
var jobs = await jobsResponse.Content.ReadFromJsonAsync<IReadOnlyCollection<BackgroundJobItem>>(JsonOptions);
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var processedJobs = await jobService.ProcessPendingAsync("integration-worker", 10);
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var providerBatch = await dbContext.CommerceReconciliationBatches.AsNoTracking()
|
||||
.SingleAsync(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.Provider == PaymentProviders.WechatPay &&
|
||||
item.Source == ReconciliationSource.ProviderDownload);
|
||||
var storedJob = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "commerce_reconciliation");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
||||
Assert.NotNull(preview);
|
||||
Assert.Equal(2, preview.TotalCount);
|
||||
Assert.Equal(1, preview.PaymentCount);
|
||||
Assert.Equal(1, preview.RefundCount);
|
||||
Assert.Equal(1, preview.InvalidCount);
|
||||
Assert.Equal(1000, preview.AmountCents);
|
||||
Assert.Equal(300, preview.RefundAmountCents);
|
||||
Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode);
|
||||
Assert.Equal(ReconciliationBatchStatus.CompletedWithIssues, batch.Status);
|
||||
Assert.Equal(HttpStatusCode.OK, itemsResponse.StatusCode);
|
||||
Assert.NotNull(items);
|
||||
Assert.Equal(2, items.Items.Count);
|
||||
Assert.Equal(HttpStatusCode.OK, issuesResponse.StatusCode);
|
||||
Assert.Single(issues.Items);
|
||||
Assert.Equal(HttpStatusCode.OK, issueEventsResponse.StatusCode);
|
||||
Assert.NotNull(anomalies);
|
||||
Assert.True(anomalies.OpenReconciliationIssueCount >= 1);
|
||||
Assert.True(anomalies.PaymentMismatchCount >= 1);
|
||||
Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, jobResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, jobsResponse.StatusCode);
|
||||
Assert.NotNull(jobs);
|
||||
Assert.Contains(jobs, item => item.JobType == "commerce_reconciliation");
|
||||
Assert.True(processedJobs >= 1);
|
||||
Assert.Equal(ReconciliationBatchStatus.Pending, providerBatch.Status);
|
||||
Assert.Equal(BackgroundJobStatus.Succeeded, storedJob.Status);
|
||||
Assert.Equal(providerBatch.Id, storedJob.Result.GetProperty("batchId").GetGuid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refund_notification_is_idempotent_and_updates_order_once()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
|
||||
var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "REFUND-NOTIFY-ORDER");
|
||||
var payment = new Payment
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = PaymentProviders.WechatPay,
|
||||
Method = "jsapi",
|
||||
Status = PaymentStatus.Paid,
|
||||
AmountCents = order.AmountCents,
|
||||
ProviderTradeNo = "wx-paid-trade",
|
||||
PaidAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
var refund = new CommerceRefundRequest
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
OrderId = order.Id,
|
||||
PaymentId = payment.Id,
|
||||
RequestedBy = seed.UserId,
|
||||
ReviewedBy = seed.UserId,
|
||||
ProcessedBy = seed.UserId,
|
||||
RefundNo = "REFUND-NOTIFY-1",
|
||||
Provider = PaymentProviders.WechatPay,
|
||||
Status = CommerceRefundStatus.Processing,
|
||||
AmountCents = 400,
|
||||
Reason = "integration test",
|
||||
RequestedAt = DateTimeOffset.UtcNow,
|
||||
ReviewedAt = DateTimeOffset.UtcNow,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await factory.SeedAsync(order, payment, refund);
|
||||
using var client = factory.CreateClient();
|
||||
var request = new RefundNotificationDto
|
||||
{
|
||||
RefundNo = refund.RefundNo,
|
||||
ProviderRefundNo = "wx-refund-notify-1",
|
||||
Status = CommerceRefundStatus.Succeeded,
|
||||
EventId = "refund-event-1",
|
||||
Payload = JsonSerializer.SerializeToElement(new { status = "SUCCESS" })
|
||||
};
|
||||
|
||||
var first = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request);
|
||||
var second = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedOrder = await dbContext.Orders.AsNoTracking().SingleAsync(item => item.Id == order.Id);
|
||||
var storedPayment = await dbContext.Payments.AsNoTracking().SingleAsync(item => item.Id == payment.Id);
|
||||
var storedRefund = await dbContext.CommerceRefundRequests.AsNoTracking().SingleAsync(item => item.Id == refund.Id);
|
||||
var matchingEvents = await dbContext.PaymentEvents.AsNoTracking()
|
||||
.CountAsync(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.Provider == PaymentProviders.WechatPay &&
|
||||
item.EventType == "refund" &&
|
||||
item.EventId == "refund-event-1");
|
||||
var refundEvents = await dbContext.CommerceRefundEvents.AsNoTracking()
|
||||
.CountAsync(item => item.TenantId == seed.TenantId && item.RefundRequestId == refund.Id);
|
||||
|
||||
Assert.Equal(400, storedOrder.RefundedAmountCents);
|
||||
Assert.Equal(OrderStatus.PartiallyRefunded, storedOrder.Status);
|
||||
Assert.Equal(400, storedPayment.RefundedAmountCents);
|
||||
Assert.Equal(PaymentStatus.PartiallyRefunded, storedPayment.Status);
|
||||
Assert.Equal(CommerceRefundStatus.Succeeded, storedRefund.Status);
|
||||
Assert.Equal("wx-refund-notify-1", storedRefund.ProviderRefundNo);
|
||||
Assert.Equal(1, matchingEvents);
|
||||
Assert.Equal(1, refundEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_can_create_review_and_report_adjustment_vouchers()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway());
|
||||
var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin);
|
||||
var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "ADJUSTMENT-ORDER");
|
||||
var payment = new Payment
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = PaymentProviders.WechatPay,
|
||||
Method = "jsapi",
|
||||
Status = PaymentStatus.Paid,
|
||||
AmountCents = order.AmountCents,
|
||||
ProviderTradeNo = "adjustment-trade",
|
||||
PaidAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await factory.SeedAsync(order, payment);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-commerce/adjustment-vouchers",
|
||||
new CreateAdjustmentVoucherDto
|
||||
{
|
||||
OrderId = order.Id,
|
||||
PaymentId = payment.Id,
|
||||
Direction = CommerceAdjustmentDirection.IncreaseRevenue,
|
||||
AmountCents = 100,
|
||||
Reason = "manual reconciliation adjustment",
|
||||
ProofAssetKey = "proofs/adjustment.txt"
|
||||
});
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<CommerceAdjustmentVoucher>(JsonOptions);
|
||||
var pendingResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-commerce/adjustment-vouchers/status",
|
||||
new UpdateAdjustmentVoucherStatusDto
|
||||
{
|
||||
VoucherId = created!.Id,
|
||||
Status = CommerceAdjustmentVoucherStatus.PendingReview,
|
||||
Note = "submit"
|
||||
});
|
||||
var approvedResponse = await client.PostAsJsonAsync(
|
||||
"/api/tenant-commerce/adjustment-vouchers/status",
|
||||
new UpdateAdjustmentVoucherStatusDto
|
||||
{
|
||||
VoucherId = created.Id,
|
||||
Status = CommerceAdjustmentVoucherStatus.Approved,
|
||||
Note = "approved"
|
||||
});
|
||||
var detailResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/detail?voucherId={created.Id}");
|
||||
var eventsResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/events?voucherId={created.Id}");
|
||||
var listResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers?status=approved");
|
||||
var reportResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers/report");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, pendingResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, approvedResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
Assert.Contains("increaseRevenueCents", await reportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(CommerceAdjustmentVoucherStatus.Approved, await dbContext.CommerceAdjustmentVouchers
|
||||
.Where(item => item.Id == created.Id)
|
||||
.Select(item => item.Status)
|
||||
.SingleAsync());
|
||||
Assert.Equal(3, await dbContext.CommerceAdjustmentVoucherEvents.CountAsync(item => item.VoucherId == created.Id));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
item.Action == "commerce.adjustment_voucher.status_changed"));
|
||||
}
|
||||
|
||||
private static async Task<LoginSeed> SeedLoginUserAsync(ApiTestFactory factory, TenantRole role)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
@@ -286,7 +638,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
|
||||
private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid regionId, string orderNo) => new()
|
||||
private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid? regionId, string orderNo) => new()
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
|
||||
Reference in New Issue
Block a user