feat: complete phase six backoffice operations

This commit is contained in:
2026-07-28 14:31:43 +08:00
parent 747ff59d76
commit 99e4e43122
31 changed files with 23504 additions and 26 deletions

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

View File

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

View File

@@ -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]

View File

@@ -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)

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

View File

@@ -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("查询学生备注")]

View File

@@ -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)

View File

@@ -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

View File

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

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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 }

View File

@@ -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];

View File

@@ -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>();

View File

@@ -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(

View File

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

View File

@@ -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();

View File

@@ -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");

View File

@@ -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,

View File

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

View File

@@ -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)

View File

@@ -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>();

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

View File

@@ -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();

View File

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

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

View File

@@ -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();

View File

@@ -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,