refactor(architecture): enforce module boundaries
This commit is contained in:
@@ -50,8 +50,8 @@ dotnet run --project Tiku.Worker
|
||||
- 平台管理端:首次在 `Tiku.PlatformAdmin.Web` 执行 `npm install`;之后启动 `Tiku.Api` 时会在 Development 自动启动前端,访问 <http://localhost:5173>
|
||||
- Scalar:<http://localhost:5090/scalar/v1>
|
||||
- OpenAPI:<http://localhost:5090/openapi/v1.json>
|
||||
- Liveness:<http://localhost:5090/api/health>
|
||||
- Readiness:<http://localhost:5090/api/health/ready>
|
||||
- Liveness:<http://localhost:5090/api/system/health>
|
||||
- Readiness:<http://localhost:5090/api/system/health/ready>
|
||||
|
||||
## 运行时边界
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ internal static class ObservabilityExtensions
|
||||
serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString()))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/health"))
|
||||
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/system/health"))
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddSource("Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -13,12 +12,12 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("学生端-资源访问")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/assets")]
|
||||
[Route("api/student/assets")]
|
||||
public sealed class AssetsController(
|
||||
IAssetAccessService assetAccessService,
|
||||
ITenantContext currentTenant,
|
||||
ICurrentUser currentUser,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[HttpGet("{assetId:guid}/download")]
|
||||
[EndpointSummary("获取资源下载地址")]
|
||||
@@ -86,13 +85,7 @@ public sealed class AssetsController(
|
||||
throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
var tenantId = await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == resolvedTenantCode.Trim() &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,20 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-认证")]
|
||||
[Route("api/auth")]
|
||||
[Route("api/platform/auth")]
|
||||
[Route("api/tenant/auth")]
|
||||
[Route("api/student/auth")]
|
||||
[Produces("application/json")]
|
||||
public sealed class AuthController(
|
||||
IAuthService authService,
|
||||
|
||||
@@ -8,10 +8,11 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-后台任务")]
|
||||
[Route("api/backoffice/tenant/jobs")]
|
||||
[Route("api/tenant/access/jobs")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
public sealed class BackgroundJobsController(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
IBackgroundJobQueue backgroundJobQueue,
|
||||
IBackgroundJobOperations backgroundJobOperations,
|
||||
ITenantContext tenantContext,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
@@ -24,7 +25,7 @@ public sealed class BackgroundJobsController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
return Ok(await backgroundJobService.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken));
|
||||
return Ok(await backgroundJobOperations.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -35,14 +36,14 @@ public sealed class BackgroundJobsController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
|
||||
return Ok(await backgroundJobQueue.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("{jobId:guid}")]
|
||||
[EndpointSummary("查询租户后台任务详情")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Detail(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await backgroundJobService.GetAsync(jobId, ResolveTenantId(), cancellationToken);
|
||||
var item = await backgroundJobOperations.GetAsync(jobId, ResolveTenantId(), cancellationToken);
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ public sealed class BackgroundJobsController(
|
||||
CancelBackgroundJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RequestCancellationAsync(
|
||||
return Ok(await backgroundJobOperations.RequestCancellationAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), request.Reason, cancellationToken));
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ public sealed class BackgroundJobsController(
|
||||
[EndpointSummary("重试失败或已取消的租户后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Retry(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RetryAsync(
|
||||
return Ok(await backgroundJobOperations.RetryAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), cancellationToken));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@ using Microsoft.Extensions.Options;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-浏览器认证")]
|
||||
[Route("api/browser-auth")]
|
||||
[Route("api/tenant/auth/browser")]
|
||||
[Produces("application/json")]
|
||||
public sealed class BrowserAuthController(
|
||||
IAuthService authService,
|
||||
@@ -303,7 +303,7 @@ public sealed class BrowserAuthController(
|
||||
Secure = true,
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/browser-auth",
|
||||
Path = "/api/tenant/auth/browser",
|
||||
MaxAge = TimeSpan.FromDays(30)
|
||||
});
|
||||
Response.Cookies.Append(BrowserAuthOptions.CsrfCookie,
|
||||
@@ -319,7 +319,7 @@ public sealed class BrowserAuthController(
|
||||
private void ClearCookies()
|
||||
{
|
||||
Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/browser-auth" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/tenant/auth/browser" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
@@ -9,8 +8,8 @@ using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.StudyContent;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -18,7 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("学生端-公开目录")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/catalog")]
|
||||
[Route("api/public/catalog")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
public sealed class CatalogController(
|
||||
ICatalogQueryService catalogQueryService,
|
||||
@@ -27,7 +26,7 @@ public sealed class CatalogController(
|
||||
IStudyContentQueryService studyContentQueryService,
|
||||
IAssetQueryService assetQueryService,
|
||||
ITenantContext currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[HttpGet("regions")]
|
||||
[EndpointSummary("查询可用地区")]
|
||||
@@ -486,14 +485,8 @@ public sealed class CatalogController(
|
||||
throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
var tenantId = await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == tenantCode.Trim() &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/commerce")]
|
||||
[Route("api/student/commerce")]
|
||||
public sealed class CommerceController(
|
||||
ICommerceService commerceService,
|
||||
ICommerceAdminService commerceAdminService,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = BackendPermissions.TenantCommissionManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/commission")]
|
||||
[Route("api/tenant/commission")]
|
||||
public sealed class CommissionController(
|
||||
ICommissionService commissionService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Crm)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/crm")]
|
||||
[Route("api/tenant/crm")]
|
||||
public sealed class CrmController(
|
||||
ICrmService crmService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -11,10 +9,8 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-系统健康")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/health")]
|
||||
public sealed class HealthController(
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore) : ControllerBase
|
||||
[Route("api/system/health")]
|
||||
public sealed class HealthController(IDependencyReadinessProbe readinessProbe) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("健康检查")]
|
||||
@@ -32,10 +28,8 @@ public sealed class HealthController(
|
||||
[EndpointSummary("依赖就绪检查")]
|
||||
public async Task<ActionResult<object>> Ready(CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var ready = database && redis;
|
||||
var response = new { status = ready ? "ready" : "not_ready", checkedAt = DateTimeOffset.UtcNow };
|
||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
var readiness = await readinessProbe.CheckAsync(cancellationToken);
|
||||
var response = new { status = readiness.Ready ? "ready" : "not_ready", readiness.CheckedAt };
|
||||
return readiness.Ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/learning")]
|
||||
[Route("api/student/learning")]
|
||||
public sealed class LearningController(
|
||||
ILearningActivityService learningActivityService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-当前用户")]
|
||||
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
|
||||
[Route("api/me")]
|
||||
[Route("api/tenant/me")]
|
||||
public sealed class MeController(
|
||||
ICurrentUser currentUser,
|
||||
IAuthSessionStore sessionStore,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ICurrentIdentityQueryService identityQueries) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("获取当前登录用户")]
|
||||
@@ -27,34 +25,23 @@ public sealed class MeController(
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var user = await dbContext.Users.FindAsync([currentUser.UserId.Value], cancellationToken);
|
||||
var user = await identityQueries.GetUserAsync(currentUser.UserId.Value, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var memberships = await dbContext.TenantMemberships
|
||||
.Where(membership =>
|
||||
membership.UserId == user.Id &&
|
||||
membership.Status == MembershipStatus.Active)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
membership => membership.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(membership, tenant) => new TenantMembershipResponse(
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
tenant.Slug,
|
||||
membership.Role,
|
||||
membership.Status))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return Ok(new MeResponse(
|
||||
user.Id,
|
||||
user.UserId,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
user.Name,
|
||||
memberships));
|
||||
user.Tenants.Select(item => new TenantMembershipResponse(
|
||||
item.TenantId,
|
||||
item.TenantName,
|
||||
item.TenantSlug,
|
||||
item.Role,
|
||||
item.Status)).ToArray()));
|
||||
}
|
||||
|
||||
[HttpGet("sessions")]
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-平台管理")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformDashboardView)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin")]
|
||||
[Route("api/platform")]
|
||||
public sealed class PlatformAdminController(
|
||||
IPlatformAdminService platformAdminService,
|
||||
IPlatformApprovalService approvalService,
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-审批中心")]
|
||||
[Route("api/platform-admin/approvals")]
|
||||
[Route("api/platform/approvals")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformApprovalView)]
|
||||
public sealed class PlatformApprovalsController(
|
||||
IPlatformApprovalService approvalService,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-后台权限")]
|
||||
[Route("api/backoffice/platform")]
|
||||
[Route("api/platform/access")]
|
||||
public sealed class PlatformBackofficeController(
|
||||
IBackofficeService backofficeService,
|
||||
IPlatformApprovalService approvalService,
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-账务回调")]
|
||||
[Route("api/platform-billing/callbacks")]
|
||||
[Route("api/integrations/platform-billing/callbacks")]
|
||||
public sealed class PlatformBillingCallbackController(
|
||||
IPlatformBillingNotificationService notificationService) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-治理配置")]
|
||||
[Route("api/platform-admin/governance")]
|
||||
[Route("api/platform/governance")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
public sealed class PlatformGovernanceController(
|
||||
IPlatformGovernanceService governanceService,
|
||||
|
||||
@@ -3,56 +3,34 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.PlatformAdmin.Operations;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-运维")]
|
||||
[Route("api/platform-admin/operations")]
|
||||
[Route("api/platform/operations")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsView)]
|
||||
public sealed class PlatformOperationsController(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
IBackgroundJobOperations backgroundJobService,
|
||||
ICurrentUser currentUser,
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
IAssetSecurityScanner assetSecurityScanner,
|
||||
IObjectStorageService objectStorageService,
|
||||
IOptions<AliyunOssOptions> aliyunOssOptions) : ControllerBase
|
||||
IPlatformOperationsQueryService operationsQueries) : ControllerBase
|
||||
{
|
||||
[HttpGet("health")]
|
||||
[EndpointSummary("查询受保护的依赖深度健康状态")]
|
||||
public async Task<ActionResult<object>> Health(CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var clamAv = await assetSecurityScanner.CheckHealthAsync(cancellationToken);
|
||||
var storageProvider = objectStorageService.ConfiguredDefaultProvider();
|
||||
var storageConfigured = storageProvider switch
|
||||
{
|
||||
ObjectStorageProviders.AliyunOss => aliyunOssOptions.Value.IsConfigured,
|
||||
ObjectStorageProviders.LocalDev => true,
|
||||
_ => false
|
||||
};
|
||||
var newestHeartbeat = await dbContext.WorkerHeartbeats.AsNoTracking()
|
||||
.MaxAsync(item => (DateTimeOffset?)item.LastHeartbeatAt, cancellationToken);
|
||||
var workerReady = newestHeartbeat >= DateTimeOffset.UtcNow.AddMinutes(-2);
|
||||
var health = await operationsQueries.GetHealthAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
status = database && redis && clamAv && workerReady && storageConfigured ? "healthy" : "degraded",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
worker = new { ready = workerReady, lastHeartbeatAt = newestHeartbeat },
|
||||
clamAv,
|
||||
storage = new { provider = storageProvider, configured = storageConfigured },
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
status = health.Healthy ? "healthy" : "degraded",
|
||||
database = health.Database,
|
||||
redis = new { configured = health.RedisConfigured, ready = health.RedisReady },
|
||||
worker = new { ready = health.WorkerReady, lastHeartbeatAt = health.LastHeartbeatAt },
|
||||
clamAv = health.ClamAv,
|
||||
storage = new { provider = health.StorageProvider, configured = health.StorageConfigured },
|
||||
checkedAt = health.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,23 +38,7 @@ public sealed class PlatformOperationsController(
|
||||
[EndpointSummary("查询 Worker 与周期循环状态")]
|
||||
public async Task<ActionResult<object>> Workers(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var items = await dbContext.WorkerHeartbeats.AsNoTracking()
|
||||
.OrderBy(item => item.WorkerId)
|
||||
.ThenBy(item => item.Processor)
|
||||
.Select(item => new
|
||||
{
|
||||
item.WorkerId,
|
||||
item.Processor,
|
||||
item.StartedAt,
|
||||
item.LastHeartbeatAt,
|
||||
item.LastIterationStartedAt,
|
||||
item.LastIterationCompletedAt,
|
||||
item.LastSucceededAt,
|
||||
item.LastError,
|
||||
item.IsRunning
|
||||
})
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var items = await operationsQueries.GetWorkersAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
staleAfterSeconds = 120,
|
||||
@@ -91,7 +53,7 @@ public sealed class PlatformOperationsController(
|
||||
item.LastSucceededAt,
|
||||
item.LastError,
|
||||
item.IsRunning,
|
||||
stale = item.LastHeartbeatAt < now.AddMinutes(-2)
|
||||
stale = item.Stale
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -100,23 +62,14 @@ public sealed class PlatformOperationsController(
|
||||
[EndpointSummary("查询后台任务队列指标")]
|
||||
public async Task<ActionResult<object>> JobMetrics(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var counts = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.GroupBy(item => item.Status)
|
||||
.Select(group => new { status = group.Key, count = group.Count() })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var oldestPending = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.Where(item => item.Status == BackgroundJobStatus.Pending)
|
||||
.MinAsync(item => (DateTimeOffset?)item.CreatedAt, cancellationToken);
|
||||
var expiredLeases = await dbContext.BackgroundJobs.AsNoTracking()
|
||||
.CountAsync(item => item.Status == BackgroundJobStatus.Processing && item.LockExpiresAt < now, cancellationToken);
|
||||
var metrics = await operationsQueries.GetJobMetricsAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
counts,
|
||||
oldestPendingAt = oldestPending,
|
||||
queueAgeSeconds = oldestPending.HasValue ? Math.Max(0, (now - oldestPending.Value).TotalSeconds) : 0,
|
||||
expiredLeases,
|
||||
checkedAt = now
|
||||
counts = metrics.Counts.Select(item => new { status = item.Status, count = item.Count }),
|
||||
oldestPendingAt = metrics.OldestPendingAt,
|
||||
queueAgeSeconds = metrics.QueueAgeSeconds,
|
||||
expiredLeases = metrics.ExpiredLeases,
|
||||
checkedAt = metrics.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,20 +77,15 @@ public sealed class PlatformOperationsController(
|
||||
[EndpointSummary("查询审批、配置与通知治理指标")]
|
||||
public async Task<ActionResult<object>> GovernanceMetrics(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var approvalCounts = await dbContext.PlatformApprovalRequests.AsNoTracking()
|
||||
.GroupBy(item => item.Status)
|
||||
.Select(group => new { status = group.Key, count = group.Count() })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var expiredPending = await dbContext.PlatformApprovalRequests.AsNoTracking()
|
||||
.CountAsync(item => item.Status == PlatformApprovalRequestStatus.Pending && item.ExpiresAt <= now, cancellationToken);
|
||||
var configurationDrafts = await dbContext.PlatformConfigurationVersions.AsNoTracking()
|
||||
.CountAsync(item => item.Status == PlatformConfigurationVersionStatus.Draft, cancellationToken);
|
||||
var notificationCounts = await dbContext.PlatformNotificationDeliveries.AsNoTracking()
|
||||
.GroupBy(item => item.Status)
|
||||
.Select(group => new { status = group.Key, count = group.Count() })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return Ok(new { approvals = approvalCounts, expiredPending, configurationDrafts, notifications = notificationCounts, checkedAt = now });
|
||||
var metrics = await operationsQueries.GetGovernanceMetricsAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
approvals = metrics.Approvals.Select(item => new { status = item.Status, count = item.Count }),
|
||||
expiredPending = metrics.ExpiredPending,
|
||||
configurationDrafts = metrics.ConfigurationDrafts,
|
||||
notifications = metrics.Notifications.Select(item => new { status = item.Status, count = item.Count }),
|
||||
checkedAt = metrics.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-支付设置")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/payment-settings")]
|
||||
[Route("api/platform/payment-settings")]
|
||||
public sealed class PlatformPaymentSettingsController(
|
||||
IPlatformPaymentSettingsService paymentSettingsService,
|
||||
IPlatformApprovalService approvalService,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-公共题库")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformQuestionBankManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/question-banks")]
|
||||
[Route("api/platform/question-banks")]
|
||||
public sealed class PlatformQuestionBanksController(
|
||||
IPlatformQuestionBankService service,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-SaaS 套餐")]
|
||||
[Route("api/platform-admin/saas")]
|
||||
[Route("api/platform/saas")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
public sealed class PlatformSaasController(
|
||||
ISaasCatalogAdminService catalogService,
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-租户 CRM 能力")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/tenant-capabilities/crm")]
|
||||
[Route("api/platform/tenant-capabilities/crm")]
|
||||
public sealed class PlatformAdminCrmController(
|
||||
IPlatformCrmAdminService crmService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
@@ -57,7 +57,7 @@ public sealed class PlatformAdminCrmController(
|
||||
[Tags("平台端-租户短信能力")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/tenant-capabilities/sms")]
|
||||
[Route("api/platform/tenant-capabilities/sms")]
|
||||
public sealed class PlatformAdminSmsController(
|
||||
IPlatformSmsAdminService smsService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
@@ -119,7 +119,7 @@ public sealed class PlatformAdminSmsController(
|
||||
[Tags("平台端-租户支付能力")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/tenant-capabilities/payments")]
|
||||
[Route("api/platform/tenant-capabilities/payments")]
|
||||
public sealed class PlatformAdminTenantPaymentSettingsController(
|
||||
IPlatformTenantPaymentAdminService paymentService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-租户生命周期")]
|
||||
[Route("api/platform-admin/tenants/{tenantId:guid}")]
|
||||
[Route("api/platform/tenants/{tenantId:guid}")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
public sealed class PlatformTenantLifecycleController(
|
||||
ITenantLifecycleService lifecycleService,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/points")]
|
||||
[Route("api/student/points")]
|
||||
public sealed class PointsController(
|
||||
IPointService pointService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Profile;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -11,7 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("学生端-个人中心")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/profile")]
|
||||
[Route("api/student/profile")]
|
||||
public sealed class ProfileController(
|
||||
IProfileService profileService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/questions/videos")]
|
||||
[Route("api/student/questions/videos")]
|
||||
public sealed class QuestionVideosController(
|
||||
IVideoPlaybackService videoPlaybackService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -14,7 +12,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("学生端-推荐增长")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/referral")]
|
||||
[Route("api/student/referral")]
|
||||
public sealed class ReferralController(
|
||||
IReferralService referralService,
|
||||
ICurrentUser currentUser,
|
||||
@@ -96,7 +94,7 @@ public sealed class ReferralController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
[HttpGet("/api/tenant/referral/stats")]
|
||||
[Tags("租户端-推荐增长")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐人个人统计")]
|
||||
@@ -111,7 +109,7 @@ public sealed class ReferralController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("sales-stats")]
|
||||
[HttpGet("/api/tenant/referral/sales-stats")]
|
||||
[Tags("租户端-推荐增长")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询销售推荐统计排行")]
|
||||
@@ -126,7 +124,7 @@ public sealed class ReferralController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("conversion-report")]
|
||||
[HttpGet("/api/tenant/referral/conversion-report")]
|
||||
[Tags("租户端-推荐增长")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐转化报告")]
|
||||
@@ -141,7 +139,7 @@ public sealed class ReferralController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("sales-clients")]
|
||||
[HttpGet("/api/tenant/referral/sales-clients")]
|
||||
[Tags("租户端-推荐增长")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐人名下客户")]
|
||||
@@ -156,7 +154,7 @@ public sealed class ReferralController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("manual-bind")]
|
||||
[HttpPost("/api/tenant/referral/manual-bind")]
|
||||
[Tags("租户端-推荐增长")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("人工调整学生推荐归属")]
|
||||
@@ -171,7 +169,7 @@ public sealed class ReferralController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("team")]
|
||||
[HttpGet("/api/tenant/referral/team")]
|
||||
[Tags("租户端-推荐增长")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("查询推荐团队成员")]
|
||||
@@ -186,7 +184,7 @@ public sealed class ReferralController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("team")]
|
||||
[HttpPut("/api/tenant/referral/team")]
|
||||
[Tags("租户端-推荐增长")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[EndpointSummary("新增或更新推荐团队关系")]
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("租户端-运行时配置")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/runtime")]
|
||||
[Route("api/public/runtime")]
|
||||
public sealed class RuntimeController(
|
||||
ITenantContext tenantContext,
|
||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -16,11 +15,11 @@ namespace Tiku.Api.Controllers;
|
||||
[AllowAnonymous]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/scoreline")]
|
||||
[Route("api/public/scoreline")]
|
||||
public sealed class ScorelineController(
|
||||
IScorelineQueryService scorelineQueryService,
|
||||
ITenantContext currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
|
||||
|
||||
@@ -133,13 +132,7 @@ public sealed class ScorelineController(
|
||||
throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
var tenantId = await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == tenantCode.Trim() &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[Route("api/_security")]
|
||||
[Route("api/system/security")]
|
||||
public sealed class SecurityDiagnosticsController(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("租户端-分类管理")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/taxonomy/nodes")]
|
||||
[Route("api/tenant/taxonomy/nodes")]
|
||||
public sealed class TaxonomyController(
|
||||
ITenantContext tenantContext,
|
||||
ITaxonomyService taxonomyService) : ControllerBase
|
||||
|
||||
@@ -7,14 +7,13 @@ using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-运营管理")]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-admin")]
|
||||
[Route("api/tenant")]
|
||||
public sealed class TenantAdminDirectController(
|
||||
ITenantAdminDirectService tenantAdminService,
|
||||
IAuthAdministrationService authAdministrationService,
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-后台权限")]
|
||||
[Route("api/backoffice/tenant")]
|
||||
[Route("api/tenant/access")]
|
||||
public sealed class TenantBackofficeController(
|
||||
IBackofficeService backofficeService,
|
||||
ICurrentAccessContext currentAccessContext) : ControllerBase
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-账务")]
|
||||
[Route("api/tenant-billing")]
|
||||
[Route("api/tenant/billing")]
|
||||
[Authorize(Policy = BackendPermissions.TenantBillingManage)]
|
||||
public sealed class TenantBillingController(
|
||||
ITenantBillingService billingService,
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-commerce")]
|
||||
[Route("api/tenant/commerce")]
|
||||
public sealed class TenantCommerceController(
|
||||
ICommerceAdminService commerceAdminService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("租户端-内容管理")]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-content")]
|
||||
[Route("api/tenant/content")]
|
||||
public sealed class TenantContentController(
|
||||
IAssetManagementService assetManagementService,
|
||||
IContentManagementService contentManagementService,
|
||||
|
||||
@@ -14,10 +14,10 @@ namespace Tiku.Api.Controllers;
|
||||
[ApiController]
|
||||
[Tags("租户端-内容直接管理")]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-content")]
|
||||
[Route("api/tenant/content")]
|
||||
public sealed class TenantContentDirectController(
|
||||
IDirectContentService directContentService,
|
||||
IBackgroundJobService backgroundJobService,
|
||||
IBackgroundJobQueue backgroundJobService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("租户端-前端配置")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-admin/frontend-config")]
|
||||
[Route("api/tenant/frontend-config")]
|
||||
public sealed class TenantFrontendConfigController(
|
||||
ITenantContext tenantContext,
|
||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-开通引导")]
|
||||
[Route("api/tenant-onboarding")]
|
||||
[Route("api/tenant/onboarding")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
public sealed class TenantOnboardingController(
|
||||
ITenantOnboardingService onboardingService,
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
@@ -16,12 +11,12 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("租户端-公开配置")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant")]
|
||||
[Route("api/public/tenant")]
|
||||
public sealed class TenantPublicController(
|
||||
TikuDbContext dbContext,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
ITenantDirectory tenantDirectory,
|
||||
IPublicTenantConfigurationQuery publicConfiguration) : ControllerBase
|
||||
{
|
||||
[HttpGet("resolve")]
|
||||
[EndpointSummary("解析当前租户")]
|
||||
@@ -83,63 +78,12 @@ public sealed class TenantPublicController(
|
||||
return Resolve(query, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TenantLookupResult?> FindActiveTenantByCodeAsync(
|
||||
string tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == tenantCode &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => new TenantLookupResult(
|
||||
tenant.Id,
|
||||
tenant.Slug,
|
||||
tenant.Name,
|
||||
tenant.Status,
|
||||
tenant.Mode,
|
||||
null))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TenantLookupResult?> FindActiveTenantByHostAsync(
|
||||
string? host,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await dbContext.TenantDomains
|
||||
.Where(domain =>
|
||||
domain.Host == host &&
|
||||
domain.Status == TenantDomainStatus.Active)
|
||||
.Join(
|
||||
dbContext.Tenants.Where(tenant => tenant.Status == TenantStatus.Active),
|
||||
domain => domain.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(domain, tenant) => new TenantLookupResult(
|
||||
tenant.Id,
|
||||
tenant.Slug,
|
||||
tenant.Name,
|
||||
tenant.Status,
|
||||
tenant.Mode,
|
||||
domain.Host))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TenantResolveResponseDto> BuildResponseAsync(
|
||||
TenantLookupResult tenant,
|
||||
string? host,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var branding = await dbContext.TenantBrandings.FindAsync([tenant.Id], cancellationToken);
|
||||
var settings = await dbContext.TenantSettings.FindAsync([tenant.Id], cancellationToken);
|
||||
var themeConfig = await dbContext.TenantThemeConfigs
|
||||
.SingleOrDefaultAsync(
|
||||
entity => entity.TenantId == tenant.Id &&
|
||||
entity.Status == TenantThemeConfigStatus.Published,
|
||||
cancellationToken);
|
||||
var configuration = await publicConfiguration.GetAsync(tenant.Id, cancellationToken);
|
||||
|
||||
return new TenantResolveResponseDto(
|
||||
new PublicTenantDto(
|
||||
@@ -149,31 +93,19 @@ public sealed class TenantPublicController(
|
||||
tenant.Status,
|
||||
tenant.Mode,
|
||||
host),
|
||||
BuildBranding(branding, themeConfig),
|
||||
CloneOrDefault(settings?.FeatureFlags),
|
||||
CloneOrDefault(settings?.AdminFeatureFlags),
|
||||
CloneOrDefault(settings?.PublicConfig));
|
||||
}
|
||||
|
||||
private static PublicTenantBrandingDto BuildBranding(
|
||||
TenantBranding? branding,
|
||||
TenantThemeConfig? themeConfig)
|
||||
{
|
||||
if (branding is null && themeConfig is null)
|
||||
{
|
||||
return PublicTenantBrandingDto.Empty;
|
||||
}
|
||||
|
||||
return new PublicTenantBrandingDto(
|
||||
branding?.BrandName,
|
||||
branding?.ShortName,
|
||||
branding?.Slogan,
|
||||
branding?.LogoUrl,
|
||||
branding?.FaviconUrl,
|
||||
branding?.ServiceWechat,
|
||||
branding?.ServiceAccountName,
|
||||
IsNonEmptyObject(themeConfig?.ActiveTheme) ? themeConfig!.ActiveTheme.Clone() : CloneOrDefault(branding?.Theme),
|
||||
IsNonEmptyObject(themeConfig?.ActivePublicAssets) ? themeConfig!.ActivePublicAssets.Clone() : CloneOrDefault(branding?.PublicAssets));
|
||||
new PublicTenantBrandingDto(
|
||||
configuration.BrandName,
|
||||
configuration.ShortName,
|
||||
configuration.Slogan,
|
||||
configuration.LogoUrl,
|
||||
configuration.FaviconUrl,
|
||||
configuration.ServiceWechat,
|
||||
configuration.ServiceAccountName,
|
||||
configuration.Theme,
|
||||
configuration.PublicAssets),
|
||||
configuration.FeatureFlags,
|
||||
configuration.AdminFeatureFlags,
|
||||
configuration.PublicConfig);
|
||||
}
|
||||
|
||||
private static string? NormalizeTenantCode(string? value)
|
||||
@@ -192,17 +124,6 @@ public sealed class TenantPublicController(
|
||||
return host.Split(':', 2)[0];
|
||||
}
|
||||
|
||||
private static bool IsNonEmptyObject(JsonElement? value)
|
||||
{
|
||||
return value is { ValueKind: JsonValueKind.Object } json &&
|
||||
json.EnumerateObject().Any();
|
||||
}
|
||||
|
||||
private static JsonElement CloneOrDefault(JsonElement? value)
|
||||
{
|
||||
return value.HasValue ? value.Value.Clone() : JsonDefaults.Object();
|
||||
}
|
||||
|
||||
private sealed record TenantLookupResult(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-当前租户")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Route("api/tenants")]
|
||||
[Route("api/tenant/context")]
|
||||
public sealed class TenantsController(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ICurrentIdentityQueryService identityQueries) : ControllerBase
|
||||
{
|
||||
[HttpGet("current")]
|
||||
[EndpointSummary("查询当前租户")]
|
||||
@@ -27,29 +25,22 @@ public sealed class TenantsController(
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
var result = await dbContext.TenantMemberships
|
||||
.Where(membership =>
|
||||
membership.UserId == currentUser.UserId.Value &&
|
||||
membership.TenantId == currentTenant.TenantId.Value &&
|
||||
membership.Status == MembershipStatus.Active)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
membership => membership.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(membership, tenant) => new CurrentTenantResponse(
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
tenant.Slug,
|
||||
tenant.Status,
|
||||
membership.Role))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
var membership = await identityQueries.GetTenantMembershipAsync(
|
||||
currentUser.UserId.Value,
|
||||
currentTenant.TenantId.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (result is null)
|
||||
if (membership is null)
|
||||
{
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
return Ok(new CurrentTenantResponse(
|
||||
membership.TenantId,
|
||||
membership.TenantName,
|
||||
membership.TenantSlug,
|
||||
membership.Status,
|
||||
membership.Role));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/videos")]
|
||||
[Route("api/student/videos")]
|
||||
public sealed class VideosController(
|
||||
IVideoPlaybackService videoPlaybackService,
|
||||
ICurrentUser currentUser,
|
||||
|
||||
@@ -2,24 +2,22 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Backoffice;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Learning;
|
||||
using Tiku.Infrastructure.Profile;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Backoffice;
|
||||
using Tiku.Application.Learning;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ public sealed class TenantResolutionOptions
|
||||
public string[] PlatformHosts { get; set; } = ["localhost", "127.0.0.1"];
|
||||
public string[] ExemptPathPrefixes { get; set; } = ["/health", "/openapi", "/scalar"];
|
||||
public string[] TenantCodePathPrefixes { get; set; } =
|
||||
["/api/auth", "/api/tenant", "/api/catalog", "/api/assets", "/api/scoreline", "/api/referral", "/api/commerce/payments/notify"];
|
||||
["/api/tenant/auth", "/api/tenant", "/api/public/catalog", "/api/student/assets", "/api/public/scoreline", "/api/student/referral", "/api/student/commerce/payments/notify"];
|
||||
public string[] TrustedProxyAddresses { get; set; } = [];
|
||||
}
|
||||
|
||||
34
Tiku.Application/Auth/CurrentIdentityQueries.cs
Normal file
34
Tiku.Application/Auth/CurrentIdentityQueries.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Application.Auth;
|
||||
|
||||
public sealed record CurrentUserTenant(
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
string TenantSlug,
|
||||
TenantRole Role,
|
||||
MembershipStatus Status);
|
||||
|
||||
public sealed record CurrentUserProfile(
|
||||
Guid UserId,
|
||||
string? Phone,
|
||||
string? Email,
|
||||
string? Name,
|
||||
IReadOnlyCollection<CurrentUserTenant> Tenants);
|
||||
|
||||
public sealed record CurrentTenantMembership(
|
||||
Guid TenantId,
|
||||
string TenantName,
|
||||
string TenantSlug,
|
||||
TenantStatus Status,
|
||||
TenantRole Role);
|
||||
|
||||
public interface ICurrentIdentityQueryService
|
||||
{
|
||||
Task<CurrentUserProfile?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CurrentTenantMembership?> GetTenantMembershipAsync(
|
||||
Guid userId,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
6
Tiku.Application/Backoffice/BackofficeException.cs
Normal file
6
Tiku.Application/Backoffice/BackofficeException.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Tiku.Application.Backoffice;
|
||||
|
||||
public sealed class BackofficeException(string message, string code) : InvalidOperationException(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
5
Tiku.Application/Content/ContentExceptions.cs
Normal file
5
Tiku.Application/Content/ContentExceptions.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Tiku.Application.Content;
|
||||
|
||||
public sealed class RequiredFieldException(string message) : Exception(message);
|
||||
|
||||
public sealed class ContentNavigationNotFoundException(string message) : Exception(message);
|
||||
@@ -35,12 +35,16 @@ public sealed record BackgroundJobItem(
|
||||
Guid? OutputAssetId,
|
||||
JsonElement Result);
|
||||
|
||||
public interface IBackgroundJobService
|
||||
public interface IBackgroundJobQueue
|
||||
{
|
||||
Task<BackgroundJobItem> EnqueueAsync(
|
||||
CreateBackgroundJobCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
}
|
||||
|
||||
public interface IBackgroundJobProcessor
|
||||
{
|
||||
Task<int> ProcessPendingAsync(
|
||||
string workerId,
|
||||
int batchSize,
|
||||
@@ -54,6 +58,10 @@ public interface IBackgroundJobService
|
||||
string workerId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
}
|
||||
|
||||
public interface IBackgroundJobOperations
|
||||
{
|
||||
Task<IReadOnlyCollection<BackgroundJobItem>> ListAsync(
|
||||
Guid tenantId,
|
||||
string? jobType = null,
|
||||
@@ -85,3 +93,23 @@ public interface IBackgroundJobService
|
||||
Guid actorUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IBackgroundJobService :
|
||||
IBackgroundJobQueue,
|
||||
IBackgroundJobProcessor,
|
||||
IBackgroundJobOperations;
|
||||
|
||||
public sealed record BackgroundJobExecutionContext(
|
||||
Guid JobId,
|
||||
Guid TenantId,
|
||||
string JobType,
|
||||
JsonElement Payload);
|
||||
|
||||
public interface IBackgroundJobHandler
|
||||
{
|
||||
string JobType { get; }
|
||||
|
||||
Task<JsonElement> HandleAsync(
|
||||
BackgroundJobExecutionContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
12
Tiku.Application/Learning/LearningExceptions.cs
Normal file
12
Tiku.Application/Learning/LearningExceptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Tiku.Application.Learning;
|
||||
|
||||
public class LearningException(string code, string message) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
|
||||
public sealed class LearningResourceNotFoundException(string code, string message)
|
||||
: LearningException(code, message);
|
||||
|
||||
public sealed class LearningValidationException(string code, string message)
|
||||
: LearningException(code, message);
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace Tiku.Application.PlatformAdmin.Operations;
|
||||
|
||||
public sealed record PlatformDependencyHealth(
|
||||
bool Healthy,
|
||||
bool Database,
|
||||
bool RedisConfigured,
|
||||
bool RedisReady,
|
||||
bool WorkerReady,
|
||||
DateTimeOffset? LastHeartbeatAt,
|
||||
bool ClamAv,
|
||||
string StorageProvider,
|
||||
bool StorageConfigured,
|
||||
DateTimeOffset CheckedAt);
|
||||
|
||||
public sealed record PlatformWorkerState(
|
||||
string WorkerId,
|
||||
string Processor,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset LastHeartbeatAt,
|
||||
DateTimeOffset? LastIterationStartedAt,
|
||||
DateTimeOffset? LastIterationCompletedAt,
|
||||
DateTimeOffset? LastSucceededAt,
|
||||
string? LastError,
|
||||
bool IsRunning,
|
||||
bool Stale);
|
||||
|
||||
public sealed record PlatformMetricCount(string Status, int Count);
|
||||
|
||||
public sealed record PlatformJobMetrics(
|
||||
IReadOnlyCollection<PlatformMetricCount> Counts,
|
||||
DateTimeOffset? OldestPendingAt,
|
||||
double QueueAgeSeconds,
|
||||
int ExpiredLeases,
|
||||
DateTimeOffset CheckedAt);
|
||||
|
||||
public sealed record PlatformGovernanceMetrics(
|
||||
IReadOnlyCollection<PlatformMetricCount> Approvals,
|
||||
int ExpiredPending,
|
||||
int ConfigurationDrafts,
|
||||
IReadOnlyCollection<PlatformMetricCount> Notifications,
|
||||
DateTimeOffset CheckedAt);
|
||||
|
||||
public interface IPlatformOperationsQueryService
|
||||
{
|
||||
Task<PlatformDependencyHealth> GetHealthAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformWorkerState>> GetWorkersAsync(CancellationToken cancellationToken = default);
|
||||
Task<PlatformJobMetrics> GetJobMetricsAsync(CancellationToken cancellationToken = default);
|
||||
Task<PlatformGovernanceMetrics> GetGovernanceMetricsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
6
Tiku.Application/Profile/ProfileException.cs
Normal file
6
Tiku.Application/Profile/ProfileException.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Tiku.Application.Profile;
|
||||
|
||||
public sealed class ProfileException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
5
Tiku.Application/QuestionBanks/QuestionBankExceptions.cs
Normal file
5
Tiku.Application/QuestionBanks/QuestionBankExceptions.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Tiku.Application.QuestionBanks;
|
||||
|
||||
public sealed class QuestionBankRequiredFieldException(string message) : Exception(message);
|
||||
|
||||
public sealed class QuestionBankNotFoundException(string message) : Exception(message);
|
||||
6
Tiku.Application/Scoreline/ScorelineQueryException.cs
Normal file
6
Tiku.Application/Scoreline/ScorelineQueryException.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Tiku.Application.Scoreline;
|
||||
|
||||
public sealed class ScorelineQueryException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
8
Tiku.Application/Security/DependencyReadiness.cs
Normal file
8
Tiku.Application/Security/DependencyReadiness.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public sealed record DependencyReadiness(bool Ready, DateTimeOffset CheckedAt);
|
||||
|
||||
public interface IDependencyReadinessProbe
|
||||
{
|
||||
Task<DependencyReadiness> CheckAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
24
Tiku.Application/Tenancy/PublicTenantConfiguration.cs
Normal file
24
Tiku.Application/Tenancy/PublicTenantConfiguration.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tiku.Application.Tenancy;
|
||||
|
||||
public sealed record PublicTenantConfiguration(
|
||||
string? BrandName,
|
||||
string? ShortName,
|
||||
string? Slogan,
|
||||
string? LogoUrl,
|
||||
string? FaviconUrl,
|
||||
string? ServiceWechat,
|
||||
string? ServiceAccountName,
|
||||
JsonElement Theme,
|
||||
JsonElement PublicAssets,
|
||||
JsonElement FeatureFlags,
|
||||
JsonElement AdminFeatureFlags,
|
||||
JsonElement PublicConfig);
|
||||
|
||||
public interface IPublicTenantConfigurationQuery
|
||||
{
|
||||
Task<PublicTenantConfiguration> GetAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -14,958 +14,17 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed class AssetManagementService(
|
||||
public sealed partial class AssetManagementService(
|
||||
TikuDbContext dbContext,
|
||||
IObjectStorageService objectStorageService,
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
IFeatureAccessService featureAccessService,
|
||||
IBackgroundJobService backgroundJobService) : IAssetManagementService
|
||||
IBackgroundJobQueue backgroundJobService) : IAssetManagementService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
private static readonly TimeSpan DefaultUploadTtl = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan MaxUploadTtl = TimeSpan.FromHours(1);
|
||||
|
||||
public async Task<CatalogList<ContentAssetManagementItem>> GetAssetsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetManagementFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssets
|
||||
.AsNoTracking()
|
||||
.Where(asset => asset.TenantId == actor.TenantId);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value);
|
||||
}
|
||||
|
||||
if (filter.CategoryId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value);
|
||||
}
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.AssetType) &&
|
||||
Enum.TryParse<ContentAssetType>(filter.AssetType, ignoreCase: true, out var assetType))
|
||||
{
|
||||
query = query.Where(asset => asset.AssetType == assetType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.UploadStatus) &&
|
||||
Enum.TryParse<AssetUploadStatus>(filter.UploadStatus, ignoreCase: true, out var uploadStatus))
|
||||
{
|
||||
query = query.Where(asset => asset.UploadStatus == uploadStatus);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) &&
|
||||
Enum.TryParse<AssetSecurityScanStatus>(filter.SecurityScanStatus, ignoreCase: true, out var securityScanStatus))
|
||||
{
|
||||
query = query.Where(asset => asset.SecurityScanStatus == securityScanStatus);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Category))
|
||||
{
|
||||
var category = filter.Category.Trim();
|
||||
query = query.Where(asset => asset.Category == category);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(asset =>
|
||||
(asset.Title != null && asset.Title.Contains(keyword)) ||
|
||||
(asset.FileName != null && asset.FileName.Contains(keyword)) ||
|
||||
(asset.Description != null && asset.Description.Contains(keyword)) ||
|
||||
(asset.AssetKey != null && asset.AssetKey.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(asset => asset.SortOrder)
|
||||
.ThenByDescending(asset => asset.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(asset => ToItem(asset))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ContentAssetManagementItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> UpsertAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken);
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
asset.ContentNodeId = command.ContentNodeId;
|
||||
asset.LegacyId = NormalizeOptional(command.LegacyId);
|
||||
asset.AssetKey = NormalizeOptional(command.AssetKey);
|
||||
asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源";
|
||||
asset.Category = NormalizeOptional(command.Category);
|
||||
asset.Description = NormalizeOptional(command.Description);
|
||||
asset.FileName = NormalizeOptional(command.FileName);
|
||||
asset.CdnUrl = NormalizeOptional(command.CdnUrl);
|
||||
asset.IsPublic = command.IsPublic ?? asset.IsPublic;
|
||||
asset.AssetType = ParseEnum(command.AssetType, asset.AssetType);
|
||||
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
|
||||
asset.Status = ParseEnum(command.Status, asset.Status);
|
||||
asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider)));
|
||||
asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim();
|
||||
asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey)
|
||||
? asset.ObjectKey
|
||||
: objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim());
|
||||
asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes);
|
||||
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
|
||||
asset.PreviewUrl = NormalizeOptional(command.PreviewUrl);
|
||||
asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey;
|
||||
asset.SortOrder = command.Order ?? asset.SortOrder;
|
||||
asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
public async Task<AssetUploadSignResult> SignUploadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType);
|
||||
|
||||
var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken);
|
||||
var provider = storageConfig.Provider;
|
||||
var bucket = storageConfig.Bucket;
|
||||
var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes);
|
||||
var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken);
|
||||
var objectKey = objectStorageService.ValidateObjectKey(
|
||||
actor.TenantId,
|
||||
string.IsNullOrWhiteSpace(command.ObjectKey)
|
||||
? CreateObjectKey(actor.TenantId, asset.Id, command.FileName)
|
||||
: command.ObjectKey.Trim());
|
||||
|
||||
objectStorageService.AssertUploadProvider(provider);
|
||||
objectStorageService.AssertWritableLocation(new StorageAssetLocation(provider, bucket, objectKey));
|
||||
|
||||
asset.StorageProvider = ToAssetStorageProvider(provider);
|
||||
asset.Bucket = bucket;
|
||||
asset.ObjectKey = objectKey;
|
||||
asset.FileName = command.FileName.Trim();
|
||||
asset.MimeType = mimeType;
|
||||
asset.FileSizeBytes = fileSizeBytes;
|
||||
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256);
|
||||
asset.UploadStatus = AssetUploadStatus.Pending;
|
||||
asset.VerifiedAt = null;
|
||||
asset.VerifiedBy = null;
|
||||
asset.VerificationDetails = JsonDefaults.Object();
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.PreviewStatus = ResolveInitialPreviewStatus(asset.AssetType, mimeType);
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
var expiresIn = ResolveUploadTtl(command.ExpiresInSeconds);
|
||||
var upload = await objectStorageService.SignUploadAsync(
|
||||
new ObjectStorageUploadSignRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
bucket,
|
||||
objectKey,
|
||||
asset.FileName,
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
expiresIn,
|
||||
Upsert: true),
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new AssetUploadSignResult(ToItem(asset), upload);
|
||||
}
|
||||
|
||||
public async Task<AssetUploadConfirmResult> ConfirmUploadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadConfirmCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets
|
||||
.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.AssetId &&
|
||||
item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket))
|
||||
{
|
||||
throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing");
|
||||
}
|
||||
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim();
|
||||
var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes;
|
||||
var declaredChecksum = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
|
||||
var metadata = await objectStorageService.HeadObjectAsync(
|
||||
new ObjectStorageHeadRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
asset.Bucket,
|
||||
asset.ObjectKey,
|
||||
declaredMimeType,
|
||||
declaredSize,
|
||||
declaredChecksum),
|
||||
cancellationToken);
|
||||
|
||||
asset.VerificationDetails = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
metadata.Exists,
|
||||
metadata.SizeBytes,
|
||||
metadata.MimeType,
|
||||
metadata.ChecksumSha256,
|
||||
metadata.ETag,
|
||||
metadata.LastModified,
|
||||
metadata.VerificationSource,
|
||||
declared = new
|
||||
{
|
||||
MimeType = declaredMimeType,
|
||||
FileSizeBytes = declaredSize,
|
||||
ChecksumSha256 = declaredChecksum
|
||||
}
|
||||
});
|
||||
|
||||
if (!metadata.Exists)
|
||||
{
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing");
|
||||
}
|
||||
if (metadata.SizeBytes is not { } verifiedSizeBytes)
|
||||
{
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException(
|
||||
"Object storage did not return a verified asset size.",
|
||||
"asset_upload_size_unverified");
|
||||
}
|
||||
|
||||
asset.UploadStatus = AssetUploadStatus.Verified;
|
||||
asset.VerifiedAt = DateTimeOffset.UtcNow;
|
||||
asset.VerifiedBy = actor.UserId;
|
||||
asset.VerifiedSizeBytes = verifiedSizeBytes;
|
||||
asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum;
|
||||
asset.MimeType = metadata.MimeType ?? declaredMimeType;
|
||||
asset.FileSizeBytes = verifiedSizeBytes;
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
|
||||
await backgroundJobService.EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
actor.TenantId,
|
||||
"asset_security_scan",
|
||||
JsonSerializer.SerializeToElement(new { assetId = asset.Id }),
|
||||
MaxRetries: 5,
|
||||
IdempotencyKey: $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}",
|
||||
IsSystemJob: true),
|
||||
cancellationToken);
|
||||
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == assetId,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
if (asset.Status == ContentStatus.Archived)
|
||||
{
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
var accountedBytes = AccountedStorageBytes(asset);
|
||||
asset.Status = ContentStatus.Archived;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (accountedBytes > 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
accountedBytes,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignPreviewAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentAssetAccessEventItem>> GetAccessEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssetAccessEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
|
||||
if (filter.UserId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetAccessEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.UserId,
|
||||
item.ActorRole,
|
||||
item.AccessType,
|
||||
item.Visibility,
|
||||
item.AssetType,
|
||||
item.StorageProvider,
|
||||
item.Disposition,
|
||||
item.ExpiresInSeconds,
|
||||
item.SignatureMode,
|
||||
item.Result,
|
||||
item.DenyCode,
|
||||
item.IpAddress,
|
||||
item.UserAgent,
|
||||
item.Metadata,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetAccessEventItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentAssetSecurityScanEventItem>> GetSecurityScanEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetSecurityScanEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.Provider,
|
||||
item.ScanStatus,
|
||||
item.RiskLevel,
|
||||
item.IssueCodes,
|
||||
item.Details,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetSecurityScanEventItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
|
||||
AssetManagementActor actor,
|
||||
ImportJobFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentImportJobs
|
||||
.AsNoTracking()
|
||||
.Where(job => job.TenantId == actor.TenantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Status) &&
|
||||
Enum.TryParse<ContentImportStatus>(filter.Status, ignoreCase: true, out var status))
|
||||
{
|
||||
query = query.Where(job => job.Status == status);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.ImportType) &&
|
||||
Enum.TryParse<ContentImportType>(filter.ImportType, ignoreCase: true, out var importType))
|
||||
{
|
||||
query = query.Where(job => job.ImportType == importType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SourceFormat) &&
|
||||
Enum.TryParse<ImportSourceFormat>(filter.SourceFormat, ignoreCase: true, out var sourceFormat))
|
||||
{
|
||||
query = query.Where(job => job.SourceFormat == sourceFormat);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(job => job.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(job => ToJobItem(job))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ContentImportJobItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentImportJobDetail> GetImportJobAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = await dbContext.ContentImportJobs
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
|
||||
.Select(item => ToJobItem(item))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
throw new AssetManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
|
||||
var items = await dbContext.ContentImportItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
|
||||
.OrderBy(item => item.RowNo)
|
||||
.Take(MaxLimit)
|
||||
.Select(item => new ContentImportItemModel(
|
||||
item.Id,
|
||||
item.JobId,
|
||||
item.RowNo,
|
||||
item.ExternalId,
|
||||
item.Status,
|
||||
item.TargetType,
|
||||
item.TargetId,
|
||||
item.SourcePayload,
|
||||
item.NormalizedPayload,
|
||||
item.ContentHash,
|
||||
item.IssuesCount))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var issues = await dbContext.ContentImportIssues
|
||||
.AsNoTracking()
|
||||
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
|
||||
.OrderBy(issue => issue.RowNo)
|
||||
.ThenBy(issue => issue.CreatedAt)
|
||||
.Take(MaxLimit)
|
||||
.Select(issue => new ContentImportIssueModel(
|
||||
issue.Id,
|
||||
issue.JobId,
|
||||
issue.ItemId,
|
||||
issue.RowNo,
|
||||
issue.Severity,
|
||||
issue.Code,
|
||||
issue.FieldPath,
|
||||
issue.Message,
|
||||
issue.Details))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new ContentImportJobDetail(job, items, issues);
|
||||
}
|
||||
|
||||
private async Task<ContentAsset> ResolveUploadAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadSignCommand command,
|
||||
string provider,
|
||||
string bucket,
|
||||
string mimeType,
|
||||
long? fileSizeBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentAsset? asset = null;
|
||||
if (command.AssetId.HasValue)
|
||||
{
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
asset = new ContentAsset
|
||||
{
|
||||
Id = command.AssetId ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
Source = "manual",
|
||||
Status = ContentStatus.Active
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
}
|
||||
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
asset.ContentNodeId = command.ContentNodeId;
|
||||
asset.AssetKey = NormalizeOptional(command.AssetKey);
|
||||
asset.Title = NormalizeOptional(command.Title) ?? command.FileName.Trim();
|
||||
asset.Category = NormalizeOptional(command.Category);
|
||||
asset.Description = NormalizeOptional(command.Description);
|
||||
asset.IsPublic = command.IsPublic ?? false;
|
||||
asset.AssetType = ResolveAssetType(command.AssetType, mimeType);
|
||||
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
|
||||
asset.StorageProvider = ToAssetStorageProvider(provider);
|
||||
asset.Bucket = bucket;
|
||||
asset.FileSizeBytes = fileSizeBytes;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : command.Metadata;
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<ContentAsset> ResolveManagementAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentAsset? asset = null;
|
||||
if (command.AssetId.HasValue)
|
||||
{
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(command.LegacyId))
|
||||
{
|
||||
var legacyId = command.LegacyId.Trim();
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.LegacyId == legacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (asset is not null)
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
|
||||
asset = new ContentAsset
|
||||
{
|
||||
Id = command.AssetId ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
UpdatedBy = actor.UserId,
|
||||
Source = "manual",
|
||||
Status = ContentStatus.Active
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<AssetManagementSignedAccessResult> SignAssetAccessAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
AssetAccessType accessType,
|
||||
string disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var objectKey = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewObjectKey ?? asset.ObjectKey
|
||||
: asset.ObjectKey;
|
||||
var cdnUrl = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewUrl ?? asset.CdnUrl
|
||||
: asset.CdnUrl;
|
||||
var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600));
|
||||
var url = await objectStorageService.SignDownloadAsync(
|
||||
new ObjectStorageDownloadSignRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
asset.Bucket,
|
||||
objectKey,
|
||||
expiresIn,
|
||||
cdnUrl,
|
||||
asset.FileName,
|
||||
disposition),
|
||||
cancellationToken);
|
||||
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
AssetId = asset.Id,
|
||||
UserId = actor.UserId,
|
||||
ActorRole = AssetAccessActorRole.TenantAdmin,
|
||||
AccessType = accessType,
|
||||
Visibility = asset.Visibility.ToString(),
|
||||
AssetType = asset.AssetType.ToString(),
|
||||
StorageProvider = asset.StorageProvider.ToString(),
|
||||
Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment,
|
||||
ExpiresInSeconds = (int)expiresIn.TotalSeconds,
|
||||
SignatureMode = url.SignatureMode,
|
||||
Result = AssetAccessResult.Granted,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
url.Provider,
|
||||
url.Bucket,
|
||||
url.ObjectKey
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new AssetManagementSignedAccessResult(ToItem(asset), url);
|
||||
}
|
||||
|
||||
private static ContentAssetManagementItem ToItem(ContentAsset asset)
|
||||
{
|
||||
return new ContentAssetManagementItem(
|
||||
asset.Id,
|
||||
asset.LegacyId,
|
||||
asset.RegionId,
|
||||
asset.SubjectId,
|
||||
asset.CategoryId,
|
||||
asset.ContentNodeId,
|
||||
asset.AssetKey,
|
||||
asset.Title,
|
||||
asset.Category,
|
||||
asset.Description,
|
||||
asset.FileName,
|
||||
asset.CdnUrl,
|
||||
asset.IsPublic,
|
||||
asset.AssetType,
|
||||
asset.StorageProvider,
|
||||
asset.Bucket,
|
||||
asset.ObjectKey,
|
||||
asset.MimeType,
|
||||
asset.FileSizeBytes,
|
||||
asset.ChecksumSha256,
|
||||
asset.Visibility,
|
||||
asset.Status,
|
||||
asset.SortOrder,
|
||||
asset.UploadStatus,
|
||||
asset.VerifiedAt,
|
||||
asset.VerifiedSizeBytes,
|
||||
asset.VerifiedChecksumSha256,
|
||||
asset.PreviewStatus,
|
||||
asset.SecurityScanStatus,
|
||||
asset.Metadata,
|
||||
asset.CreatedAt,
|
||||
asset.UpdatedAt);
|
||||
}
|
||||
|
||||
private async Task SaveWithStorageQuotaAdjustmentAsync(
|
||||
Guid tenantId,
|
||||
long byteDelta,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (byteDelta > 0)
|
||||
{
|
||||
var reserved = await featureAccessService.TryConsumeQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
cancellationToken);
|
||||
if (!reserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant storage quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (byteDelta < 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
-byteDelta,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static long AccountedStorageBytes(ContentAsset asset)
|
||||
{
|
||||
return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0
|
||||
? asset.VerifiedSizeBytes.Value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static ContentImportJobItem ToJobItem(ContentImportJob job)
|
||||
{
|
||||
return new ContentImportJobItem(
|
||||
job.Id,
|
||||
job.TargetRegionId,
|
||||
job.TargetSubjectId,
|
||||
job.TargetCategoryId,
|
||||
job.TargetContentNodeId,
|
||||
job.TargetQuestionBankId,
|
||||
job.ImportType,
|
||||
job.SourceFormat,
|
||||
job.Status,
|
||||
job.SourceName,
|
||||
job.SourceHash,
|
||||
job.DryRun,
|
||||
job.TotalCount,
|
||||
job.ValidCount,
|
||||
job.ErrorCount,
|
||||
job.WarningCount,
|
||||
job.InsertedCount,
|
||||
job.UpdatedCount,
|
||||
job.SkippedCount,
|
||||
job.Summary,
|
||||
job.ErrorMessage,
|
||||
job.StartedAt,
|
||||
job.FinishedAt,
|
||||
job.CreatedAt,
|
||||
job.UpdatedAt);
|
||||
}
|
||||
|
||||
private static string CreateObjectKey(Guid tenantId, Guid assetId, string fileName)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return $"{tenantId:N}/assets/{now:yyyy}/{now:MM}/{assetId:N}/{SanitizeFileName(fileName)}";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var trimmed = Path.GetFileName(fileName.Trim());
|
||||
return string.Join(
|
||||
"-",
|
||||
trimmed.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeChecksum(string? checksum)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(checksum) ? null : checksum.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static TimeSpan ResolveUploadTtl(int? expiresInSeconds)
|
||||
{
|
||||
if (!expiresInSeconds.HasValue || expiresInSeconds <= 0)
|
||||
{
|
||||
return DefaultUploadTtl;
|
||||
}
|
||||
|
||||
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
|
||||
return requested <= MaxUploadTtl ? requested : MaxUploadTtl;
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
if (!limit.HasValue || limit <= 0)
|
||||
{
|
||||
return DefaultLimit;
|
||||
}
|
||||
|
||||
return Math.Min(limit.Value, MaxLimit);
|
||||
}
|
||||
|
||||
private static ContentAssetType ResolveAssetType(string? value, string mimeType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
Enum.TryParse<ContentAssetType>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var normalizedMimeType = mimeType.ToLowerInvariant();
|
||||
if (normalizedMimeType == "application/pdf")
|
||||
{
|
||||
return ContentAssetType.Pdf;
|
||||
}
|
||||
|
||||
if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Image;
|
||||
}
|
||||
|
||||
if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Video;
|
||||
}
|
||||
|
||||
if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Audio;
|
||||
}
|
||||
|
||||
return ContentAssetType.Document;
|
||||
}
|
||||
|
||||
private static ContentVisibility ResolveVisibility(string? value, bool isPublic)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
Enum.TryParse<ContentVisibility>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return isPublic ? ContentVisibility.Public : ContentVisibility.Members;
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType)
|
||||
{
|
||||
return assetType is ContentAssetType.Pdf or ContentAssetType.Image ||
|
||||
mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) ||
|
||||
mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|
||||
? AssetPreviewStatus.Pending
|
||||
: AssetPreviewStatus.None;
|
||||
}
|
||||
|
||||
private static AssetStorageProvider ToAssetStorageProvider(string provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl,
|
||||
ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
|
||||
ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos,
|
||||
ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo,
|
||||
ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
|
||||
_ => throw new AssetManagementException("Storage provider is not supported.", "storage_provider_not_supported")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToObjectStorageProvider(AssetStorageProvider provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
|
||||
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
|
||||
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
|
||||
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
||||
_ => ObjectStorageProviders.ExternalUrl
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var account = await providerConfigService.GetActiveProviderAsync(
|
||||
tenantId,
|
||||
TenantExternalProviderCapability.ObjectStorage,
|
||||
cancellationToken: cancellationToken);
|
||||
var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket");
|
||||
if (string.IsNullOrWhiteSpace(bucket))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
"Object storage provider bucket is not configured.",
|
||||
"STORAGE_BUCKET_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim());
|
||||
}
|
||||
catch (TenantExternalProviderException)
|
||||
{
|
||||
return (
|
||||
objectStorageService.ConfiguredDefaultProvider(),
|
||||
objectStorageService.ConfiguredDefaultBucket());
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed partial class AssetManagementService
|
||||
{
|
||||
public async Task<CatalogList<ContentAssetAccessEventItem>> GetAccessEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssetAccessEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
|
||||
if (filter.UserId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UserId == filter.UserId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetAccessEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.UserId,
|
||||
item.ActorRole,
|
||||
item.AccessType,
|
||||
item.Visibility,
|
||||
item.AssetType,
|
||||
item.StorageProvider,
|
||||
item.Disposition,
|
||||
item.ExpiresInSeconds,
|
||||
item.SignatureMode,
|
||||
item.Result,
|
||||
item.DenyCode,
|
||||
item.IpAddress,
|
||||
item.UserAgent,
|
||||
item.Metadata,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetAccessEventItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentAssetSecurityScanEventItem>> GetSecurityScanEventsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetEventFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.AssetId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.AssetId == filter.AssetId.Value);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new ContentAssetSecurityScanEventItem(
|
||||
item.Id,
|
||||
item.AssetId,
|
||||
item.Provider,
|
||||
item.ScanStatus,
|
||||
item.RiskLevel,
|
||||
item.IssueCodes,
|
||||
item.Details,
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentAssetSecurityScanEventItem>(items);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed partial class AssetManagementService
|
||||
{
|
||||
public async Task<CatalogList<ContentAssetManagementItem>> GetAssetsAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetManagementFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentAssets
|
||||
.AsNoTracking()
|
||||
.Where(asset => asset.TenantId == actor.TenantId);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value);
|
||||
}
|
||||
|
||||
if (filter.CategoryId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value);
|
||||
}
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.AssetType) &&
|
||||
Enum.TryParse<ContentAssetType>(filter.AssetType, ignoreCase: true, out var assetType))
|
||||
{
|
||||
query = query.Where(asset => asset.AssetType == assetType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.UploadStatus) &&
|
||||
Enum.TryParse<AssetUploadStatus>(filter.UploadStatus, ignoreCase: true, out var uploadStatus))
|
||||
{
|
||||
query = query.Where(asset => asset.UploadStatus == uploadStatus);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) &&
|
||||
Enum.TryParse<AssetSecurityScanStatus>(filter.SecurityScanStatus, ignoreCase: true, out var securityScanStatus))
|
||||
{
|
||||
query = query.Where(asset => asset.SecurityScanStatus == securityScanStatus);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Category))
|
||||
{
|
||||
var category = filter.Category.Trim();
|
||||
query = query.Where(asset => asset.Category == category);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(asset =>
|
||||
(asset.Title != null && asset.Title.Contains(keyword)) ||
|
||||
(asset.FileName != null && asset.FileName.Contains(keyword)) ||
|
||||
(asset.Description != null && asset.Description.Contains(keyword)) ||
|
||||
(asset.AssetKey != null && asset.AssetKey.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(asset => asset.SortOrder)
|
||||
.ThenByDescending(asset => asset.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(asset => ToItem(asset))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ContentAssetManagementItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> UpsertAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken);
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
asset.ContentNodeId = command.ContentNodeId;
|
||||
asset.LegacyId = NormalizeOptional(command.LegacyId);
|
||||
asset.AssetKey = NormalizeOptional(command.AssetKey);
|
||||
asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源";
|
||||
asset.Category = NormalizeOptional(command.Category);
|
||||
asset.Description = NormalizeOptional(command.Description);
|
||||
asset.FileName = NormalizeOptional(command.FileName);
|
||||
asset.CdnUrl = NormalizeOptional(command.CdnUrl);
|
||||
asset.IsPublic = command.IsPublic ?? asset.IsPublic;
|
||||
asset.AssetType = ParseEnum(command.AssetType, asset.AssetType);
|
||||
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
|
||||
asset.Status = ParseEnum(command.Status, asset.Status);
|
||||
asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider)));
|
||||
asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim();
|
||||
asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey)
|
||||
? asset.ObjectKey
|
||||
: objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim());
|
||||
asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes);
|
||||
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
|
||||
asset.PreviewUrl = NormalizeOptional(command.PreviewUrl);
|
||||
asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey;
|
||||
asset.SortOrder = command.Order ?? asset.SortOrder;
|
||||
asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed partial class AssetManagementService
|
||||
{
|
||||
private async Task<ContentAsset> ResolveUploadAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadSignCommand command,
|
||||
string provider,
|
||||
string bucket,
|
||||
string mimeType,
|
||||
long? fileSizeBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentAsset? asset = null;
|
||||
if (command.AssetId.HasValue)
|
||||
{
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
asset = new ContentAsset
|
||||
{
|
||||
Id = command.AssetId ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
Source = "manual",
|
||||
Status = ContentStatus.Active
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
}
|
||||
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
asset.ContentNodeId = command.ContentNodeId;
|
||||
asset.AssetKey = NormalizeOptional(command.AssetKey);
|
||||
asset.Title = NormalizeOptional(command.Title) ?? command.FileName.Trim();
|
||||
asset.Category = NormalizeOptional(command.Category);
|
||||
asset.Description = NormalizeOptional(command.Description);
|
||||
asset.IsPublic = command.IsPublic ?? false;
|
||||
asset.AssetType = ResolveAssetType(command.AssetType, mimeType);
|
||||
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
|
||||
asset.StorageProvider = ToAssetStorageProvider(provider);
|
||||
asset.Bucket = bucket;
|
||||
asset.FileSizeBytes = fileSizeBytes;
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : command.Metadata;
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<ContentAsset> ResolveManagementAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
UpsertAssetCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ContentAsset? asset = null;
|
||||
if (command.AssetId.HasValue)
|
||||
{
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(command.LegacyId))
|
||||
{
|
||||
var legacyId = command.LegacyId.Trim();
|
||||
asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.LegacyId == legacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (asset is not null)
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
|
||||
asset = new ContentAsset
|
||||
{
|
||||
Id = command.AssetId ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
UpdatedBy = actor.UserId,
|
||||
Source = "manual",
|
||||
Status = ContentStatus.Active
|
||||
};
|
||||
dbContext.ContentAssets.Add(asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async Task<AssetManagementSignedAccessResult> SignAssetAccessAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
AssetAccessType accessType,
|
||||
string disposition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.AssetId && item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var objectKey = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewObjectKey ?? asset.ObjectKey
|
||||
: asset.ObjectKey;
|
||||
var cdnUrl = accessType == AssetAccessType.AdminPreview
|
||||
? asset.PreviewUrl ?? asset.CdnUrl
|
||||
: asset.CdnUrl;
|
||||
var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600));
|
||||
var url = await objectStorageService.SignDownloadAsync(
|
||||
new ObjectStorageDownloadSignRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
asset.Bucket,
|
||||
objectKey,
|
||||
expiresIn,
|
||||
cdnUrl,
|
||||
asset.FileName,
|
||||
disposition),
|
||||
cancellationToken);
|
||||
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
AssetId = asset.Id,
|
||||
UserId = actor.UserId,
|
||||
ActorRole = AssetAccessActorRole.TenantAdmin,
|
||||
AccessType = accessType,
|
||||
Visibility = asset.Visibility.ToString(),
|
||||
AssetType = asset.AssetType.ToString(),
|
||||
StorageProvider = asset.StorageProvider.ToString(),
|
||||
Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment,
|
||||
ExpiresInSeconds = (int)expiresIn.TotalSeconds,
|
||||
SignatureMode = url.SignatureMode,
|
||||
Result = AssetAccessResult.Granted,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
url.Provider,
|
||||
url.Bucket,
|
||||
url.ObjectKey
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new AssetManagementSignedAccessResult(ToItem(asset), url);
|
||||
}
|
||||
|
||||
private static ContentAssetManagementItem ToItem(ContentAsset asset)
|
||||
{
|
||||
return new ContentAssetManagementItem(
|
||||
asset.Id,
|
||||
asset.LegacyId,
|
||||
asset.RegionId,
|
||||
asset.SubjectId,
|
||||
asset.CategoryId,
|
||||
asset.ContentNodeId,
|
||||
asset.AssetKey,
|
||||
asset.Title,
|
||||
asset.Category,
|
||||
asset.Description,
|
||||
asset.FileName,
|
||||
asset.CdnUrl,
|
||||
asset.IsPublic,
|
||||
asset.AssetType,
|
||||
asset.StorageProvider,
|
||||
asset.Bucket,
|
||||
asset.ObjectKey,
|
||||
asset.MimeType,
|
||||
asset.FileSizeBytes,
|
||||
asset.ChecksumSha256,
|
||||
asset.Visibility,
|
||||
asset.Status,
|
||||
asset.SortOrder,
|
||||
asset.UploadStatus,
|
||||
asset.VerifiedAt,
|
||||
asset.VerifiedSizeBytes,
|
||||
asset.VerifiedChecksumSha256,
|
||||
asset.PreviewStatus,
|
||||
asset.SecurityScanStatus,
|
||||
asset.Metadata,
|
||||
asset.CreatedAt,
|
||||
asset.UpdatedAt);
|
||||
}
|
||||
|
||||
private async Task SaveWithStorageQuotaAdjustmentAsync(
|
||||
Guid tenantId,
|
||||
long byteDelta,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (byteDelta > 0)
|
||||
{
|
||||
var reserved = await featureAccessService.TryConsumeQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
cancellationToken);
|
||||
if (!reserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant storage quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (byteDelta < 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
-byteDelta,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static long AccountedStorageBytes(ContentAsset asset)
|
||||
{
|
||||
return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0
|
||||
? asset.VerifiedSizeBytes.Value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static ContentImportJobItem ToJobItem(ContentImportJob job)
|
||||
{
|
||||
return new ContentImportJobItem(
|
||||
job.Id,
|
||||
job.TargetRegionId,
|
||||
job.TargetSubjectId,
|
||||
job.TargetCategoryId,
|
||||
job.TargetContentNodeId,
|
||||
job.TargetQuestionBankId,
|
||||
job.ImportType,
|
||||
job.SourceFormat,
|
||||
job.Status,
|
||||
job.SourceName,
|
||||
job.SourceHash,
|
||||
job.DryRun,
|
||||
job.TotalCount,
|
||||
job.ValidCount,
|
||||
job.ErrorCount,
|
||||
job.WarningCount,
|
||||
job.InsertedCount,
|
||||
job.UpdatedCount,
|
||||
job.SkippedCount,
|
||||
job.Summary,
|
||||
job.ErrorMessage,
|
||||
job.StartedAt,
|
||||
job.FinishedAt,
|
||||
job.CreatedAt,
|
||||
job.UpdatedAt);
|
||||
}
|
||||
|
||||
private static string CreateObjectKey(Guid tenantId, Guid assetId, string fileName)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return $"{tenantId:N}/assets/{now:yyyy}/{now:MM}/{assetId:N}/{SanitizeFileName(fileName)}";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var trimmed = Path.GetFileName(fileName.Trim());
|
||||
return string.Join(
|
||||
"-",
|
||||
trimmed.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeChecksum(string? checksum)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(checksum) ? null : checksum.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static TimeSpan ResolveUploadTtl(int? expiresInSeconds)
|
||||
{
|
||||
if (!expiresInSeconds.HasValue || expiresInSeconds <= 0)
|
||||
{
|
||||
return DefaultUploadTtl;
|
||||
}
|
||||
|
||||
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
|
||||
return requested <= MaxUploadTtl ? requested : MaxUploadTtl;
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
if (!limit.HasValue || limit <= 0)
|
||||
{
|
||||
return DefaultLimit;
|
||||
}
|
||||
|
||||
return Math.Min(limit.Value, MaxLimit);
|
||||
}
|
||||
|
||||
private static ContentAssetType ResolveAssetType(string? value, string mimeType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
Enum.TryParse<ContentAssetType>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var normalizedMimeType = mimeType.ToLowerInvariant();
|
||||
if (normalizedMimeType == "application/pdf")
|
||||
{
|
||||
return ContentAssetType.Pdf;
|
||||
}
|
||||
|
||||
if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Image;
|
||||
}
|
||||
|
||||
if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Video;
|
||||
}
|
||||
|
||||
if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal))
|
||||
{
|
||||
return ContentAssetType.Audio;
|
||||
}
|
||||
|
||||
return ContentAssetType.Document;
|
||||
}
|
||||
|
||||
private static ContentVisibility ResolveVisibility(string? value, bool isPublic)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
Enum.TryParse<ContentVisibility>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return isPublic ? ContentVisibility.Public : ContentVisibility.Members;
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string? value, TEnum fallback)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType)
|
||||
{
|
||||
return assetType is ContentAssetType.Pdf or ContentAssetType.Image ||
|
||||
mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) ||
|
||||
mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|
||||
? AssetPreviewStatus.Pending
|
||||
: AssetPreviewStatus.None;
|
||||
}
|
||||
|
||||
private static AssetStorageProvider ToAssetStorageProvider(string provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl,
|
||||
ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
|
||||
ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos,
|
||||
ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo,
|
||||
ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
|
||||
_ => throw new AssetManagementException("Storage provider is not supported.", "storage_provider_not_supported")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToObjectStorageProvider(AssetStorageProvider provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
|
||||
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
||||
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
|
||||
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
|
||||
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
||||
_ => ObjectStorageProviders.ExternalUrl
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var account = await providerConfigService.GetActiveProviderAsync(
|
||||
tenantId,
|
||||
TenantExternalProviderCapability.ObjectStorage,
|
||||
cancellationToken: cancellationToken);
|
||||
var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket");
|
||||
if (string.IsNullOrWhiteSpace(bucket))
|
||||
{
|
||||
throw new ObjectStorageException(
|
||||
"Object storage provider bucket is not configured.",
|
||||
"STORAGE_BUCKET_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim());
|
||||
}
|
||||
catch (TenantExternalProviderException)
|
||||
{
|
||||
return (
|
||||
objectStorageService.ConfiguredDefaultProvider(),
|
||||
objectStorageService.ConfiguredDefaultBucket());
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed partial class AssetManagementService
|
||||
{
|
||||
public async Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
|
||||
AssetManagementActor actor,
|
||||
ImportJobFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentImportJobs
|
||||
.AsNoTracking()
|
||||
.Where(job => job.TenantId == actor.TenantId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Status) &&
|
||||
Enum.TryParse<ContentImportStatus>(filter.Status, ignoreCase: true, out var status))
|
||||
{
|
||||
query = query.Where(job => job.Status == status);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.ImportType) &&
|
||||
Enum.TryParse<ContentImportType>(filter.ImportType, ignoreCase: true, out var importType))
|
||||
{
|
||||
query = query.Where(job => job.ImportType == importType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.SourceFormat) &&
|
||||
Enum.TryParse<ImportSourceFormat>(filter.SourceFormat, ignoreCase: true, out var sourceFormat))
|
||||
{
|
||||
query = query.Where(job => job.SourceFormat == sourceFormat);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(job => job.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(job => ToJobItem(job))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ContentImportJobItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentImportJobDetail> GetImportJobAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = await dbContext.ContentImportJobs
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
|
||||
.Select(item => ToJobItem(item))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (job is null)
|
||||
{
|
||||
throw new AssetManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
|
||||
var items = await dbContext.ContentImportItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
|
||||
.OrderBy(item => item.RowNo)
|
||||
.Take(MaxLimit)
|
||||
.Select(item => new ContentImportItemModel(
|
||||
item.Id,
|
||||
item.JobId,
|
||||
item.RowNo,
|
||||
item.ExternalId,
|
||||
item.Status,
|
||||
item.TargetType,
|
||||
item.TargetId,
|
||||
item.SourcePayload,
|
||||
item.NormalizedPayload,
|
||||
item.ContentHash,
|
||||
item.IssuesCount))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var issues = await dbContext.ContentImportIssues
|
||||
.AsNoTracking()
|
||||
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
|
||||
.OrderBy(issue => issue.RowNo)
|
||||
.ThenBy(issue => issue.CreatedAt)
|
||||
.Take(MaxLimit)
|
||||
.Select(issue => new ContentImportIssueModel(
|
||||
issue.Id,
|
||||
issue.JobId,
|
||||
issue.ItemId,
|
||||
issue.RowNo,
|
||||
issue.Severity,
|
||||
issue.Code,
|
||||
issue.FieldPath,
|
||||
issue.Message,
|
||||
issue.Details))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new ContentImportJobDetail(job, items, issues);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed partial class AssetManagementService
|
||||
{
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == assetId,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
if (asset.Status == ContentStatus.Archived)
|
||||
{
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
var accountedBytes = AccountedStorageBytes(asset);
|
||||
asset.Status = ContentStatus.Archived;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (accountedBytes > 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
accountedBytes,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignPreviewAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed partial class AssetManagementService
|
||||
{
|
||||
public async Task<AssetUploadSignResult> SignUploadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadSignCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType);
|
||||
|
||||
var storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken);
|
||||
var provider = storageConfig.Provider;
|
||||
var bucket = storageConfig.Bucket;
|
||||
var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim());
|
||||
var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes);
|
||||
var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken);
|
||||
var objectKey = objectStorageService.ValidateObjectKey(
|
||||
actor.TenantId,
|
||||
string.IsNullOrWhiteSpace(command.ObjectKey)
|
||||
? CreateObjectKey(actor.TenantId, asset.Id, command.FileName)
|
||||
: command.ObjectKey.Trim());
|
||||
|
||||
objectStorageService.AssertUploadProvider(provider);
|
||||
objectStorageService.AssertWritableLocation(new StorageAssetLocation(provider, bucket, objectKey));
|
||||
|
||||
asset.StorageProvider = ToAssetStorageProvider(provider);
|
||||
asset.Bucket = bucket;
|
||||
asset.ObjectKey = objectKey;
|
||||
asset.FileName = command.FileName.Trim();
|
||||
asset.MimeType = mimeType;
|
||||
asset.FileSizeBytes = fileSizeBytes;
|
||||
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256);
|
||||
asset.UploadStatus = AssetUploadStatus.Pending;
|
||||
asset.VerifiedAt = null;
|
||||
asset.VerifiedBy = null;
|
||||
asset.VerificationDetails = JsonDefaults.Object();
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.PreviewStatus = ResolveInitialPreviewStatus(asset.AssetType, mimeType);
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
var expiresIn = ResolveUploadTtl(command.ExpiresInSeconds);
|
||||
var upload = await objectStorageService.SignUploadAsync(
|
||||
new ObjectStorageUploadSignRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
bucket,
|
||||
objectKey,
|
||||
asset.FileName,
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
expiresIn,
|
||||
Upsert: true),
|
||||
cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new AssetUploadSignResult(ToItem(asset), upload);
|
||||
}
|
||||
|
||||
public async Task<AssetUploadConfirmResult> ConfirmUploadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetUploadConfirmCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets
|
||||
.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.AssetId &&
|
||||
item.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket))
|
||||
{
|
||||
throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing");
|
||||
}
|
||||
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim();
|
||||
var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes;
|
||||
var declaredChecksum = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
|
||||
var metadata = await objectStorageService.HeadObjectAsync(
|
||||
new ObjectStorageHeadRequest(
|
||||
actor.TenantId,
|
||||
provider,
|
||||
asset.Bucket,
|
||||
asset.ObjectKey,
|
||||
declaredMimeType,
|
||||
declaredSize,
|
||||
declaredChecksum),
|
||||
cancellationToken);
|
||||
|
||||
asset.VerificationDetails = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
metadata.Exists,
|
||||
metadata.SizeBytes,
|
||||
metadata.MimeType,
|
||||
metadata.ChecksumSha256,
|
||||
metadata.ETag,
|
||||
metadata.LastModified,
|
||||
metadata.VerificationSource,
|
||||
declared = new
|
||||
{
|
||||
MimeType = declaredMimeType,
|
||||
FileSizeBytes = declaredSize,
|
||||
ChecksumSha256 = declaredChecksum
|
||||
}
|
||||
});
|
||||
|
||||
if (!metadata.Exists)
|
||||
{
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing");
|
||||
}
|
||||
if (metadata.SizeBytes is not { } verifiedSizeBytes)
|
||||
{
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException(
|
||||
"Object storage did not return a verified asset size.",
|
||||
"asset_upload_size_unverified");
|
||||
}
|
||||
|
||||
asset.UploadStatus = AssetUploadStatus.Verified;
|
||||
asset.VerifiedAt = DateTimeOffset.UtcNow;
|
||||
asset.VerifiedBy = actor.UserId;
|
||||
asset.VerifiedSizeBytes = verifiedSizeBytes;
|
||||
asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum;
|
||||
asset.MimeType = metadata.MimeType ?? declaredMimeType;
|
||||
asset.FileSizeBytes = verifiedSizeBytes;
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
|
||||
await backgroundJobService.EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
actor.TenantId,
|
||||
"asset_security_scan",
|
||||
JsonSerializer.SerializeToElement(new { assetId = asset.Id }),
|
||||
MaxRetries: 5,
|
||||
IdempotencyKey: $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}",
|
||||
IsSystemJob: true),
|
||||
cancellationToken);
|
||||
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -13,7 +13,7 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed class AuthService(
|
||||
public sealed partial class AuthService(
|
||||
TikuDbContext dbContext,
|
||||
SignInManager<User> signInManager,
|
||||
UserManager<User> userManager,
|
||||
@@ -31,855 +31,5 @@ public sealed class AuthService(
|
||||
private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||
private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||
|
||||
public async Task<AuthenticationResult> LoginWithPasswordAsync(
|
||||
PasswordLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var identifier = request.Phone.Trim();
|
||||
var normalizedEmail = userManager.NormalizeEmail(identifier);
|
||||
var normalizedUserName = userManager.NormalizeName(identifier);
|
||||
var user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(entity =>
|
||||
entity.Phone == identifier ||
|
||||
entity.NormalizedEmail == normalizedEmail ||
|
||||
entity.NormalizedUserName == normalizedUserName,
|
||||
cancellationToken);
|
||||
var passwordResult = user is null || user.Status != UserStatus.Active
|
||||
? SignInResult.Failed
|
||||
: await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
|
||||
|
||||
if (!passwordResult.Succeeded)
|
||||
{
|
||||
var loginResult = passwordResult.IsLockedOut
|
||||
? AuthLoginResult.Blocked
|
||||
: AuthLoginResult.Failed;
|
||||
var failureCode = passwordResult.IsLockedOut
|
||||
? "account_locked"
|
||||
: "invalid_credentials";
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
user?.Id,
|
||||
PasswordProvider,
|
||||
identifier,
|
||||
loginResult,
|
||||
failureCode,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
request.TenantId,
|
||||
user!,
|
||||
PasswordProvider,
|
||||
identifier,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> LoginWithSmsAsync(
|
||||
SmsLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (!request.TenantId.HasValue)
|
||||
{
|
||||
throw new InvalidCredentialsException("tenant_required_for_sms");
|
||||
}
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId.Value,
|
||||
phone,
|
||||
SmsPurpose.Login,
|
||||
request.Code,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (InvalidCredentialsException exception)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
user?.Id,
|
||||
SmsProvider,
|
||||
phone,
|
||||
AuthLoginResult.Failed,
|
||||
exception.Code,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
null,
|
||||
SmsProvider,
|
||||
phone,
|
||||
AuthLoginResult.Failed,
|
||||
"user_not_found",
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
request.TenantId,
|
||||
user,
|
||||
SmsProvider,
|
||||
phone,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> LoginWithWechatWebAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return LoginWithWechatAsync(
|
||||
request,
|
||||
WechatWebProvider,
|
||||
WechatWebProviderAliases,
|
||||
(options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return LoginWithWechatAsync(
|
||||
request,
|
||||
WechatMiniAppProvider,
|
||||
WechatMiniAppProviderAliases,
|
||||
(options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthTokenPair> RefreshAsync(
|
||||
RefreshSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await sessionStore.RotateAsync(
|
||||
request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(
|
||||
LogoutSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString())
|
||||
?? throw new InvalidCredentialsException();
|
||||
var stampResult = await userManager.UpdateSecurityStampAsync(user);
|
||||
if (!stampResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to update the user's security stamp.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> ChangeRequiredPasswordAsync(
|
||||
PasswordChangeChallengeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var challenge = await FindChallengeAsync(
|
||||
request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken);
|
||||
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
|
||||
?? throw new InvalidAuthChallengeException();
|
||||
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clear the password-change requirement.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
|
||||
await ConsumeChallengeAsync(challenge, cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id, challenge.TenantId, "auth.password.changed", null,
|
||||
request.IpAddress, request.UserAgent, cancellationToken);
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(),
|
||||
request.IpAddress, request.UserAgent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SmsSendResult> RequestPasswordResetAsync(
|
||||
PasswordResetCodeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var userId = await dbContext.Users.AsNoTracking()
|
||||
.Where(user => user.Phone == phone && user.Status == UserStatus.Active)
|
||||
.Select(user => (Guid?)user.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
var eligible = userId.HasValue && await dbContext.TenantMemberships.AsNoTracking().AnyAsync(
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == userId.Value &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!eligible)
|
||||
{
|
||||
return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
}
|
||||
|
||||
return await smsVerificationService.CreateCodeAsync(
|
||||
new SendSmsCodeRequest(
|
||||
request.TenantId,
|
||||
phone,
|
||||
SmsPurpose.ResetPassword,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(
|
||||
PasswordResetRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(
|
||||
item => item.Phone == phone && item.Status == UserStatus.Active,
|
||||
cancellationToken);
|
||||
if (user is null || !await dbContext.TenantMemberships.AnyAsync(
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == user.Id &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId,
|
||||
phone,
|
||||
SmsPurpose.ResetPassword,
|
||||
request.Code,
|
||||
cancellationToken);
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, token, request.NewPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password reset.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_reset", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id,
|
||||
request.TenantId,
|
||||
"auth.password.reset",
|
||||
null,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> ChangePasswordAsync(
|
||||
AuthenticatedPasswordChangeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await sessionStore.ResolveActiveSessionAsync(
|
||||
request.SessionId,
|
||||
request.UserId,
|
||||
cancellationToken) ?? throw new SessionRevokedException();
|
||||
var user = await userManager.FindByIdAsync(request.UserId.ToString())
|
||||
?? throw new InvalidCredentialsException();
|
||||
var changed = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
|
||||
if (!changed.Succeeded)
|
||||
{
|
||||
var currentPasswordInvalid = changed.Errors.Any(error =>
|
||||
string.Equals(error.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase));
|
||||
throw new InvalidCredentialsException(currentPasswordInvalid ? "invalid_credentials" : "invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password change.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id,
|
||||
session.TenantId,
|
||||
"auth.password.changed_authenticated",
|
||||
null,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
session.Realm,
|
||||
session.TenantId,
|
||||
user,
|
||||
PasswordProvider,
|
||||
user.Email ?? user.Phone ?? user.Id.ToString(),
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthChallenge> FindChallengeAsync(
|
||||
string token,
|
||||
AuthChallengePurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tokenHash = HashChallengeToken(token);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return await dbContext.AuthChallenges.SingleOrDefaultAsync(
|
||||
item => item.TokenHash == tokenHash && item.Purpose == purpose &&
|
||||
item.ConsumedAt == null && item.ExpiresAt > now &&
|
||||
dbContext.Users.Any(user =>
|
||||
user.Id == item.UserId && user.Status == UserStatus.Active &&
|
||||
user.SecurityStamp == item.SecurityStamp),
|
||||
cancellationToken)
|
||||
?? throw new InvalidAuthChallengeException();
|
||||
}
|
||||
|
||||
private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var consumed = await dbContext.AuthChallenges
|
||||
.Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken);
|
||||
if (consumed != 1)
|
||||
{
|
||||
throw new InvalidAuthChallengeException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> CompleteSuccessfulLoginAsync(
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
User user,
|
||||
string provider,
|
||||
string identifier,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (user.Status != UserStatus.Active)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
|
||||
"user_disabled", ipAddress, userAgent, cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
TenantMembership? membership = null;
|
||||
Tenant? tenant = null;
|
||||
if (realm == AuthRealm.Tenant && tenantId.HasValue)
|
||||
{
|
||||
membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken);
|
||||
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
|
||||
item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
|
||||
if (membership is null || tenant is null)
|
||||
{
|
||||
await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
|
||||
"tenant_access_denied", ipAddress, userAgent, cancellationToken);
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
}
|
||||
else if (realm == AuthRealm.Platform)
|
||||
{
|
||||
if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken))
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
null, user.Id, provider, identifier, AuthLoginResult.Failed,
|
||||
"platform_access_denied", ipAddress, userAgent, cancellationToken);
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
if (user.ForcePasswordChange)
|
||||
{
|
||||
return await CreateChallengeResultAsync(
|
||||
user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider,
|
||||
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
|
||||
}
|
||||
|
||||
return await IssueAuthenticatedResultAsync(
|
||||
user, realm, tenant, membership, provider,
|
||||
identifier, ipAddress, userAgent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> IssueAuthenticatedResultAsync(
|
||||
User user,
|
||||
AuthRealm realm,
|
||||
Tenant? tenant,
|
||||
TenantMembership? membership,
|
||||
string provider,
|
||||
string? identifier,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tokens = await sessionStore.IssueAsync(
|
||||
new AuthSessionIssueRequest(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
user.SecurityStamp ?? string.Empty,
|
||||
realm,
|
||||
tenant?.Id,
|
||||
provider,
|
||||
ipAddress,
|
||||
userAgent),
|
||||
cancellationToken);
|
||||
|
||||
await AddLoginEventAsync(
|
||||
tenant?.Id,
|
||||
user.Id,
|
||||
provider,
|
||||
identifier,
|
||||
AuthLoginResult.Success,
|
||||
null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
cancellationToken);
|
||||
|
||||
var tenantSummary = tenant is not null && membership is not null
|
||||
? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status)
|
||||
: null;
|
||||
return new AuthenticationResult(
|
||||
AuthenticationStatus.Authenticated,
|
||||
new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens));
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> LoginWithWechatAsync(
|
||||
WechatLoginRequest request,
|
||||
string provider,
|
||||
IReadOnlyList<string> providerAliases,
|
||||
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> exchangeCodeAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue)
|
||||
{
|
||||
throw new InvalidCredentialsException("tenant_realm_required_for_wechat");
|
||||
}
|
||||
|
||||
var config = await LoadWechatProviderOptionsAsync(
|
||||
request.TenantId.Value,
|
||||
provider,
|
||||
providerAliases,
|
||||
cancellationToken);
|
||||
|
||||
WechatIdentity identity;
|
||||
try
|
||||
{
|
||||
identity = await exchangeCodeAsync(config, request.Code, cancellationToken);
|
||||
}
|
||||
catch (AuthException exception)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
null,
|
||||
provider,
|
||||
null,
|
||||
AuthLoginResult.Failed,
|
||||
exception.Code,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var providerSubject = $"{config.AppId}:{identity.OpenId}";
|
||||
var user = await UpsertWechatUserAsync(
|
||||
provider,
|
||||
providerSubject,
|
||||
config.AppId,
|
||||
identity,
|
||||
cancellationToken);
|
||||
await EnsureTenantMembershipAsync(
|
||||
request.TenantId.Value,
|
||||
user.Id,
|
||||
cancellationToken);
|
||||
// Persist the external identity and membership together only after the
|
||||
// tenant policy and existing membership state have accepted the login.
|
||||
// A denied first login must not leave a user or provider identity behind.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
request.TenantId,
|
||||
user,
|
||||
provider,
|
||||
identity.OpenId,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<WechatProviderOptions> LoadWechatProviderOptionsAsync(
|
||||
Guid tenantId,
|
||||
string provider,
|
||||
IReadOnlyList<string> aliases,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TenantExternalProviderAccount? account = null;
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
try
|
||||
{
|
||||
account = await providerConfigService.GetActiveProviderAsync(
|
||||
tenantId,
|
||||
TenantExternalProviderCapability.Identity,
|
||||
alias,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
catch (TenantExternalProviderException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
throw new AuthProviderNotConfiguredException(provider);
|
||||
}
|
||||
|
||||
var appId = GetJsonString(account.ConfigPublic, "appId", "clientId");
|
||||
var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret");
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
|
||||
{
|
||||
throw new AuthProviderNotConfiguredException(provider);
|
||||
}
|
||||
|
||||
return new WechatProviderOptions(appId, appSecret);
|
||||
}
|
||||
|
||||
private async Task<User> UpsertWechatUserAsync(
|
||||
string provider,
|
||||
string providerSubject,
|
||||
string appId,
|
||||
WechatIdentity wechatIdentity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingIdentity = await dbContext.UserIdentities
|
||||
.SingleOrDefaultAsync(
|
||||
identity =>
|
||||
identity.Provider == provider &&
|
||||
identity.ProviderSubject == providerSubject,
|
||||
cancellationToken);
|
||||
var user = existingIdentity is null
|
||||
? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken)
|
||||
: await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Name = wechatIdentity.Nickname,
|
||||
AvatarUrl = wechatIdentity.AvatarUrl,
|
||||
PrimaryRole = "student",
|
||||
RawProfile = CreateWechatRawProfile(wechatIdentity)
|
||||
};
|
||||
dbContext.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.Name = string.IsNullOrWhiteSpace(user.Name) ? wechatIdentity.Nickname : user.Name;
|
||||
user.AvatarUrl = string.IsNullOrWhiteSpace(user.AvatarUrl) ? wechatIdentity.AvatarUrl : user.AvatarUrl;
|
||||
}
|
||||
|
||||
if (existingIdentity is null)
|
||||
{
|
||||
existingIdentity = new UserIdentity
|
||||
{
|
||||
UserId = user.Id,
|
||||
Provider = provider,
|
||||
ProviderSubject = providerSubject
|
||||
};
|
||||
dbContext.UserIdentities.Add(existingIdentity);
|
||||
}
|
||||
|
||||
existingIdentity.UserId = user.Id;
|
||||
existingIdentity.OpenId = wechatIdentity.OpenId;
|
||||
existingIdentity.UnionId = wechatIdentity.UnionId;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<User?> FindUserByWechatUnionIdAsync(
|
||||
string? unionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var identity = await dbContext.UserIdentities
|
||||
.Where(entity =>
|
||||
entity.UnionId == unionId &&
|
||||
WechatIdentityProviders.Contains(entity.Provider))
|
||||
.OrderBy(entity => entity.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return identity is null
|
||||
? null
|
||||
: await dbContext.Users.FindAsync([identity.UserId], cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureTenantMembershipAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync(
|
||||
membership =>
|
||||
membership.TenantId == tenantId &&
|
||||
membership.UserId == userId &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (activeMembershipExists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var studentMembership = await dbContext.TenantMemberships
|
||||
.FirstOrDefaultAsync(
|
||||
membership =>
|
||||
membership.TenantId == tenantId &&
|
||||
membership.UserId == userId &&
|
||||
membership.Role == TenantRole.Student,
|
||||
cancellationToken);
|
||||
if (studentMembership is not null)
|
||||
{
|
||||
// Invited and Disabled memberships require an explicit administrator action.
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
var policy = await dbContext.TenantAuthPolicies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (policy is not null && !policy.AllowExternalStudentSelfRegistration)
|
||||
{
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StudentCount,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
dbContext.TenantMemberships.Add(new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.TenantMemberships
|
||||
.Where(entity =>
|
||||
entity.TenantId == tenantId &&
|
||||
entity.UserId == userId &&
|
||||
entity.Status == MembershipStatus.Active)
|
||||
.OrderBy(entity => entity.Role)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> CreateChallengeResultAsync(
|
||||
User user,
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
AuthChallengePurpose purpose,
|
||||
string provider,
|
||||
AuthenticationStatus status,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
|
||||
var tenantCode = tenantId?.ToString("N") ?? "-";
|
||||
var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}";
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
|
||||
dbContext.AuthChallenges.Add(new AuthChallenge
|
||||
{
|
||||
UserId = user.Id,
|
||||
Realm = realm,
|
||||
TenantId = tenantId,
|
||||
Purpose = purpose,
|
||||
TokenHash = HashChallengeToken(rawToken),
|
||||
SecurityStamp = user.SecurityStamp ?? string.Empty,
|
||||
Provider = provider,
|
||||
ExpiresAt = expiresAt,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id, tenantId, "auth.challenge.issued", status.ToString(),
|
||||
ipAddress, userAgent, cancellationToken);
|
||||
return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt);
|
||||
}
|
||||
|
||||
private async Task<bool> HasBackendPermissionsAsync(
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
return await (
|
||||
from userRole in dbContext.PlatformBackendUserRoles
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
|
||||
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform ||
|
||||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
|
||||
select permission.Id).AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!tenantId.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await (
|
||||
from userRole in dbContext.TenantBackendUserRoles
|
||||
join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.TenantId == tenantId.Value && userRole.UserId == userId &&
|
||||
binding.TenantId == tenantId.Value &&
|
||||
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
|
||||
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant ||
|
||||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
|
||||
select permission.Id).AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string HashChallengeToken(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
|
||||
|
||||
private async Task AddSecurityAuditAsync(
|
||||
Guid userId,
|
||||
Guid? tenantId,
|
||||
string action,
|
||||
string? reason,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ActorUserId = userId,
|
||||
Action = action,
|
||||
TargetType = "user",
|
||||
TargetId = userId.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(new { reason }),
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddLoginEventAsync(
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
string provider,
|
||||
string? identifier,
|
||||
AuthLoginResult result,
|
||||
string? failureCode,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Provider = provider,
|
||||
Identifier = identifier,
|
||||
Result = result,
|
||||
FailureCode = failureCode,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (element.TryGetProperty(name, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString()!.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
openId = identity.OpenId,
|
||||
unionId = identity.UnionId,
|
||||
nickname = identity.Nickname,
|
||||
avatarUrl = identity.AvatarUrl
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
62
Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs
Normal file
62
Tiku.Infrastructure/Auth/CurrentIdentityQueryService.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
internal sealed class CurrentIdentityQueryService(TikuDbContext dbContext) : ICurrentIdentityQueryService
|
||||
{
|
||||
public async Task<CurrentUserProfile?> GetUserAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await dbContext.Users.AsNoTracking()
|
||||
.Where(item => item.Id == userId)
|
||||
.Select(item => new { item.Id, item.Phone, item.Email, item.Name })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var memberships = await dbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.UserId == userId && item.Status == MembershipStatus.Active)
|
||||
.Join(
|
||||
dbContext.Tenants.AsNoTracking(),
|
||||
membership => membership.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(membership, tenant) => new CurrentUserTenant(
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
tenant.Slug,
|
||||
membership.Role,
|
||||
membership.Status))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CurrentUserProfile(user.Id, user.Phone, user.Email, user.Name, memberships);
|
||||
}
|
||||
|
||||
public Task<CurrentTenantMembership?> GetTenantMembershipAsync(
|
||||
Guid userId,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return dbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.UserId == userId &&
|
||||
item.TenantId == tenantId &&
|
||||
item.Status == MembershipStatus.Active)
|
||||
.Join(
|
||||
dbContext.Tenants.AsNoTracking(),
|
||||
membership => membership.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(membership, tenant) => new CurrentTenantMembership(
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
tenant.Slug,
|
||||
tenant.Status,
|
||||
membership.Role))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
553
Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs
Normal file
553
Tiku.Infrastructure/Auth/Foundation/AuthService.Foundation.cs
Normal file
@@ -0,0 +1,553 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed partial class AuthService
|
||||
{
|
||||
private async Task<AuthChallenge> FindChallengeAsync(
|
||||
string token,
|
||||
AuthChallengePurpose purpose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tokenHash = HashChallengeToken(token);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return await dbContext.AuthChallenges.SingleOrDefaultAsync(
|
||||
item => item.TokenHash == tokenHash && item.Purpose == purpose &&
|
||||
item.ConsumedAt == null && item.ExpiresAt > now &&
|
||||
dbContext.Users.Any(user =>
|
||||
user.Id == item.UserId && user.Status == UserStatus.Active &&
|
||||
user.SecurityStamp == item.SecurityStamp),
|
||||
cancellationToken)
|
||||
?? throw new InvalidAuthChallengeException();
|
||||
}
|
||||
|
||||
private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var consumed = await dbContext.AuthChallenges
|
||||
.Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken);
|
||||
if (consumed != 1)
|
||||
{
|
||||
throw new InvalidAuthChallengeException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> CompleteSuccessfulLoginAsync(
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
User user,
|
||||
string provider,
|
||||
string identifier,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (user.Status != UserStatus.Active)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
|
||||
"user_disabled", ipAddress, userAgent, cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
TenantMembership? membership = null;
|
||||
Tenant? tenant = null;
|
||||
if (realm == AuthRealm.Tenant && tenantId.HasValue)
|
||||
{
|
||||
membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken);
|
||||
tenant = await dbContext.Tenants.SingleOrDefaultAsync(
|
||||
item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken);
|
||||
if (membership is null || tenant is null)
|
||||
{
|
||||
await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed,
|
||||
"tenant_access_denied", ipAddress, userAgent, cancellationToken);
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
}
|
||||
else if (realm == AuthRealm.Platform)
|
||||
{
|
||||
if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken))
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
null, user.Id, provider, identifier, AuthLoginResult.Failed,
|
||||
"platform_access_denied", ipAddress, userAgent, cancellationToken);
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
if (user.ForcePasswordChange)
|
||||
{
|
||||
return await CreateChallengeResultAsync(
|
||||
user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider,
|
||||
AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken);
|
||||
}
|
||||
|
||||
return await IssueAuthenticatedResultAsync(
|
||||
user, realm, tenant, membership, provider,
|
||||
identifier, ipAddress, userAgent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> IssueAuthenticatedResultAsync(
|
||||
User user,
|
||||
AuthRealm realm,
|
||||
Tenant? tenant,
|
||||
TenantMembership? membership,
|
||||
string provider,
|
||||
string? identifier,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tokens = await sessionStore.IssueAsync(
|
||||
new AuthSessionIssueRequest(
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
user.SecurityStamp ?? string.Empty,
|
||||
realm,
|
||||
tenant?.Id,
|
||||
provider,
|
||||
ipAddress,
|
||||
userAgent),
|
||||
cancellationToken);
|
||||
|
||||
await AddLoginEventAsync(
|
||||
tenant?.Id,
|
||||
user.Id,
|
||||
provider,
|
||||
identifier,
|
||||
AuthLoginResult.Success,
|
||||
null,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
cancellationToken);
|
||||
|
||||
var tenantSummary = tenant is not null && membership is not null
|
||||
? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status)
|
||||
: null;
|
||||
return new AuthenticationResult(
|
||||
AuthenticationStatus.Authenticated,
|
||||
new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens));
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> LoginWithWechatAsync(
|
||||
WechatLoginRequest request,
|
||||
string provider,
|
||||
IReadOnlyList<string> providerAliases,
|
||||
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> exchangeCodeAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue)
|
||||
{
|
||||
throw new InvalidCredentialsException("tenant_realm_required_for_wechat");
|
||||
}
|
||||
|
||||
var config = await LoadWechatProviderOptionsAsync(
|
||||
request.TenantId.Value,
|
||||
provider,
|
||||
providerAliases,
|
||||
cancellationToken);
|
||||
|
||||
WechatIdentity identity;
|
||||
try
|
||||
{
|
||||
identity = await exchangeCodeAsync(config, request.Code, cancellationToken);
|
||||
}
|
||||
catch (AuthException exception)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
null,
|
||||
provider,
|
||||
null,
|
||||
AuthLoginResult.Failed,
|
||||
exception.Code,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var providerSubject = $"{config.AppId}:{identity.OpenId}";
|
||||
var user = await UpsertWechatUserAsync(
|
||||
provider,
|
||||
providerSubject,
|
||||
config.AppId,
|
||||
identity,
|
||||
cancellationToken);
|
||||
await EnsureTenantMembershipAsync(
|
||||
request.TenantId.Value,
|
||||
user.Id,
|
||||
cancellationToken);
|
||||
// Persist the external identity and membership together only after the
|
||||
// tenant policy and existing membership state have accepted the login.
|
||||
// A denied first login must not leave a user or provider identity behind.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
request.TenantId,
|
||||
user,
|
||||
provider,
|
||||
identity.OpenId,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<WechatProviderOptions> LoadWechatProviderOptionsAsync(
|
||||
Guid tenantId,
|
||||
string provider,
|
||||
IReadOnlyList<string> aliases,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TenantExternalProviderAccount? account = null;
|
||||
foreach (var alias in aliases)
|
||||
{
|
||||
try
|
||||
{
|
||||
account = await providerConfigService.GetActiveProviderAsync(
|
||||
tenantId,
|
||||
TenantExternalProviderCapability.Identity,
|
||||
alias,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
catch (TenantExternalProviderException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (account is null)
|
||||
{
|
||||
throw new AuthProviderNotConfiguredException(provider);
|
||||
}
|
||||
|
||||
var appId = GetJsonString(account.ConfigPublic, "appId", "clientId");
|
||||
var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret");
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
|
||||
{
|
||||
throw new AuthProviderNotConfiguredException(provider);
|
||||
}
|
||||
|
||||
return new WechatProviderOptions(appId, appSecret);
|
||||
}
|
||||
|
||||
private async Task<User> UpsertWechatUserAsync(
|
||||
string provider,
|
||||
string providerSubject,
|
||||
string appId,
|
||||
WechatIdentity wechatIdentity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingIdentity = await dbContext.UserIdentities
|
||||
.SingleOrDefaultAsync(
|
||||
identity =>
|
||||
identity.Provider == provider &&
|
||||
identity.ProviderSubject == providerSubject,
|
||||
cancellationToken);
|
||||
var user = existingIdentity is null
|
||||
? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken)
|
||||
: await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Name = wechatIdentity.Nickname,
|
||||
AvatarUrl = wechatIdentity.AvatarUrl,
|
||||
PrimaryRole = "student",
|
||||
RawProfile = CreateWechatRawProfile(wechatIdentity)
|
||||
};
|
||||
dbContext.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.Name = string.IsNullOrWhiteSpace(user.Name) ? wechatIdentity.Nickname : user.Name;
|
||||
user.AvatarUrl = string.IsNullOrWhiteSpace(user.AvatarUrl) ? wechatIdentity.AvatarUrl : user.AvatarUrl;
|
||||
}
|
||||
|
||||
if (existingIdentity is null)
|
||||
{
|
||||
existingIdentity = new UserIdentity
|
||||
{
|
||||
UserId = user.Id,
|
||||
Provider = provider,
|
||||
ProviderSubject = providerSubject
|
||||
};
|
||||
dbContext.UserIdentities.Add(existingIdentity);
|
||||
}
|
||||
|
||||
existingIdentity.UserId = user.Id;
|
||||
existingIdentity.OpenId = wechatIdentity.OpenId;
|
||||
existingIdentity.UnionId = wechatIdentity.UnionId;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<User?> FindUserByWechatUnionIdAsync(
|
||||
string? unionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var identity = await dbContext.UserIdentities
|
||||
.Where(entity =>
|
||||
entity.UnionId == unionId &&
|
||||
WechatIdentityProviders.Contains(entity.Provider))
|
||||
.OrderBy(entity => entity.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return identity is null
|
||||
? null
|
||||
: await dbContext.Users.FindAsync([identity.UserId], cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureTenantMembershipAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync(
|
||||
membership =>
|
||||
membership.TenantId == tenantId &&
|
||||
membership.UserId == userId &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (activeMembershipExists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var studentMembership = await dbContext.TenantMemberships
|
||||
.FirstOrDefaultAsync(
|
||||
membership =>
|
||||
membership.TenantId == tenantId &&
|
||||
membership.UserId == userId &&
|
||||
membership.Role == TenantRole.Student,
|
||||
cancellationToken);
|
||||
if (studentMembership is not null)
|
||||
{
|
||||
// Invited and Disabled memberships require an explicit administrator action.
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
var policy = await dbContext.TenantAuthPolicies.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (policy is not null && !policy.AllowExternalStudentSelfRegistration)
|
||||
{
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StudentCount,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
dbContext.TenantMemberships.Add(new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.TenantMemberships
|
||||
.Where(entity =>
|
||||
entity.TenantId == tenantId &&
|
||||
entity.UserId == userId &&
|
||||
entity.Status == MembershipStatus.Active)
|
||||
.OrderBy(entity => entity.Role)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AuthenticationResult> CreateChallengeResultAsync(
|
||||
User user,
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
AuthChallengePurpose purpose,
|
||||
string provider,
|
||||
AuthenticationStatus status,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
|
||||
var tenantCode = tenantId?.ToString("N") ?? "-";
|
||||
var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}";
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5);
|
||||
dbContext.AuthChallenges.Add(new AuthChallenge
|
||||
{
|
||||
UserId = user.Id,
|
||||
Realm = realm,
|
||||
TenantId = tenantId,
|
||||
Purpose = purpose,
|
||||
TokenHash = HashChallengeToken(rawToken),
|
||||
SecurityStamp = user.SecurityStamp ?? string.Empty,
|
||||
Provider = provider,
|
||||
ExpiresAt = expiresAt,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id, tenantId, "auth.challenge.issued", status.ToString(),
|
||||
ipAddress, userAgent, cancellationToken);
|
||||
return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt);
|
||||
}
|
||||
|
||||
private async Task<bool> HasBackendPermissionsAsync(
|
||||
AuthRealm realm,
|
||||
Guid? tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
return await (
|
||||
from userRole in dbContext.PlatformBackendUserRoles
|
||||
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.UserId == userId &&
|
||||
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
|
||||
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform ||
|
||||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
|
||||
select permission.Id).AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (!tenantId.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await (
|
||||
from userRole in dbContext.TenantBackendUserRoles
|
||||
join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id
|
||||
join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId
|
||||
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
||||
where userRole.TenantId == tenantId.Value && userRole.UserId == userId &&
|
||||
binding.TenantId == tenantId.Value &&
|
||||
role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active &&
|
||||
(permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant ||
|
||||
permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both)
|
||||
select permission.Id).AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string HashChallengeToken(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant();
|
||||
|
||||
private async Task AddSecurityAuditAsync(
|
||||
Guid userId,
|
||||
Guid? tenantId,
|
||||
string action,
|
||||
string? reason,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ActorUserId = userId,
|
||||
Action = action,
|
||||
TargetType = "user",
|
||||
TargetId = userId.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(new { reason }),
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddLoginEventAsync(
|
||||
Guid? tenantId,
|
||||
Guid? userId,
|
||||
string provider,
|
||||
string? identifier,
|
||||
AuthLoginResult result,
|
||||
string? failureCode,
|
||||
string? ipAddress,
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Provider = provider,
|
||||
Identifier = identifier,
|
||||
Result = result,
|
||||
FailureCode = failureCode,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] names)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (element.TryGetProperty(name, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||
{
|
||||
return property.GetString()!.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
openId = identity.OpenId,
|
||||
unionId = identity.UnionId,
|
||||
nickname = identity.Nickname,
|
||||
avatarUrl = identity.AvatarUrl
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed partial class AuthService
|
||||
{
|
||||
public async Task<AuthenticationResult> ChangeRequiredPasswordAsync(
|
||||
PasswordChangeChallengeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var challenge = await FindChallengeAsync(
|
||||
request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken);
|
||||
var user = await userManager.FindByIdAsync(challenge.UserId.ToString())
|
||||
?? throw new InvalidAuthChallengeException();
|
||||
var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to clear the password-change requirement.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
|
||||
await ConsumeChallengeAsync(challenge, cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id, challenge.TenantId, "auth.password.changed", null,
|
||||
request.IpAddress, request.UserAgent, cancellationToken);
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(),
|
||||
request.IpAddress, request.UserAgent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SmsSendResult> RequestPasswordResetAsync(
|
||||
PasswordResetCodeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var userId = await dbContext.Users.AsNoTracking()
|
||||
.Where(user => user.Phone == phone && user.Status == UserStatus.Active)
|
||||
.Select(user => (Guid?)user.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
var eligible = userId.HasValue && await dbContext.TenantMemberships.AsNoTracking().AnyAsync(
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == userId.Value &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!eligible)
|
||||
{
|
||||
return new SmsSendResult(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
}
|
||||
|
||||
return await smsVerificationService.CreateCodeAsync(
|
||||
new SendSmsCodeRequest(
|
||||
request.TenantId,
|
||||
phone,
|
||||
SmsPurpose.ResetPassword,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(
|
||||
PasswordResetRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(
|
||||
item => item.Phone == phone && item.Status == UserStatus.Active,
|
||||
cancellationToken);
|
||||
if (user is null || !await dbContext.TenantMemberships.AnyAsync(
|
||||
membership => membership.TenantId == request.TenantId && membership.UserId == user.Id &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId,
|
||||
phone,
|
||||
SmsPurpose.ResetPassword,
|
||||
request.Code,
|
||||
cancellationToken);
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var reset = await userManager.ResetPasswordAsync(user, token, request.NewPassword);
|
||||
if (!reset.Succeeded)
|
||||
{
|
||||
throw new InvalidCredentialsException("invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password reset.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_reset", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id,
|
||||
request.TenantId,
|
||||
"auth.password.reset",
|
||||
null,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> ChangePasswordAsync(
|
||||
AuthenticatedPasswordChangeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await sessionStore.ResolveActiveSessionAsync(
|
||||
request.SessionId,
|
||||
request.UserId,
|
||||
cancellationToken) ?? throw new SessionRevokedException();
|
||||
var user = await userManager.FindByIdAsync(request.UserId.ToString())
|
||||
?? throw new InvalidCredentialsException();
|
||||
var changed = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
|
||||
if (!changed.Succeeded)
|
||||
{
|
||||
var currentPasswordInvalid = changed.Errors.Any(error =>
|
||||
string.Equals(error.Code, "PasswordMismatch", StringComparison.OrdinalIgnoreCase));
|
||||
throw new InvalidCredentialsException(currentPasswordInvalid ? "invalid_credentials" : "invalid_new_password");
|
||||
}
|
||||
|
||||
user.ForcePasswordChange = false;
|
||||
var updated = await userManager.UpdateAsync(user);
|
||||
if (!updated.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to finalize the password change.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken);
|
||||
await AddSecurityAuditAsync(
|
||||
user.Id,
|
||||
session.TenantId,
|
||||
"auth.password.changed_authenticated",
|
||||
null,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
session.Realm,
|
||||
session.TenantId,
|
||||
user,
|
||||
PasswordProvider,
|
||||
user.Email ?? user.Phone ?? user.Id.ToString(),
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed partial class AuthService
|
||||
{
|
||||
public async Task<AuthenticationResult> LoginWithPasswordAsync(
|
||||
PasswordLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var identifier = request.Phone.Trim();
|
||||
var normalizedEmail = userManager.NormalizeEmail(identifier);
|
||||
var normalizedUserName = userManager.NormalizeName(identifier);
|
||||
var user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(entity =>
|
||||
entity.Phone == identifier ||
|
||||
entity.NormalizedEmail == normalizedEmail ||
|
||||
entity.NormalizedUserName == normalizedUserName,
|
||||
cancellationToken);
|
||||
var passwordResult = user is null || user.Status != UserStatus.Active
|
||||
? SignInResult.Failed
|
||||
: await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
|
||||
|
||||
if (!passwordResult.Succeeded)
|
||||
{
|
||||
var loginResult = passwordResult.IsLockedOut
|
||||
? AuthLoginResult.Blocked
|
||||
: AuthLoginResult.Failed;
|
||||
var failureCode = passwordResult.IsLockedOut
|
||||
? "account_locked"
|
||||
: "invalid_credentials";
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
user?.Id,
|
||||
PasswordProvider,
|
||||
identifier,
|
||||
loginResult,
|
||||
failureCode,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
request.TenantId,
|
||||
user!,
|
||||
PasswordProvider,
|
||||
identifier,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
47
Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs
Normal file
47
Tiku.Infrastructure/Auth/Sessions/AuthService.Sessions.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed partial class AuthService
|
||||
{
|
||||
public async Task<AuthTokenPair> RefreshAsync(
|
||||
RefreshSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await sessionStore.RotateAsync(
|
||||
request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(
|
||||
LogoutSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId.ToString())
|
||||
?? throw new InvalidCredentialsException();
|
||||
var stampResult = await userManager.UpdateSecurityStampAsync(user);
|
||||
if (!stampResult.Succeeded)
|
||||
{
|
||||
throw new InvalidOperationException("Unable to update the user's security stamp.");
|
||||
}
|
||||
|
||||
await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
81
Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs
Normal file
81
Tiku.Infrastructure/Auth/SmsLogin/AuthService.SmsLogin.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed partial class AuthService
|
||||
{
|
||||
public async Task<AuthenticationResult> LoginWithSmsAsync(
|
||||
SmsLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var user = await dbContext.Users
|
||||
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (!request.TenantId.HasValue)
|
||||
{
|
||||
throw new InvalidCredentialsException("tenant_required_for_sms");
|
||||
}
|
||||
await smsVerificationService.VerifyCodeAsync(
|
||||
request.TenantId.Value,
|
||||
phone,
|
||||
SmsPurpose.Login,
|
||||
request.Code,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (InvalidCredentialsException exception)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
user?.Id,
|
||||
SmsProvider,
|
||||
phone,
|
||||
AuthLoginResult.Failed,
|
||||
exception.Code,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
await AddLoginEventAsync(
|
||||
request.TenantId,
|
||||
null,
|
||||
SmsProvider,
|
||||
phone,
|
||||
AuthLoginResult.Failed,
|
||||
"user_not_found",
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
request.TenantId,
|
||||
user,
|
||||
SmsProvider,
|
||||
phone,
|
||||
request.IpAddress,
|
||||
request.UserAgent,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
43
Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs
Normal file
43
Tiku.Infrastructure/Auth/Wechat/AuthService.Wechat.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Auth;
|
||||
|
||||
public sealed partial class AuthService
|
||||
{
|
||||
public Task<AuthenticationResult> LoginWithWechatWebAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return LoginWithWechatAsync(
|
||||
request,
|
||||
WechatWebProvider,
|
||||
WechatWebProviderAliases,
|
||||
(options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> LoginWithWechatMiniAppAsync(
|
||||
WechatLoginRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return LoginWithWechatAsync(
|
||||
request,
|
||||
WechatMiniAppProvider,
|
||||
WechatMiniAppProviderAliases,
|
||||
(options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -488,8 +488,3 @@ internal sealed class BackofficeService(
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed class BackofficeException(string message, string code) : InvalidOperationException(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
public async Task<CodeBatchItem> CreateCodeBatchAsync(
|
||||
CommerceAdminActor actor,
|
||||
CreateCodeBatchCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.TotalCount is < 1 or > 1000)
|
||||
{
|
||||
throw new CommerceException("Code batch total count must be between 1 and 1000.", "invalid_code_batch_count");
|
||||
}
|
||||
|
||||
if (command.Days <= 0)
|
||||
{
|
||||
throw new CommerceException("Activation code days must be positive.", "invalid_activation_days");
|
||||
}
|
||||
|
||||
if (command.RegionId.HasValue)
|
||||
{
|
||||
var regionExists = await dbContext.Regions
|
||||
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken);
|
||||
if (!regionExists)
|
||||
{
|
||||
throw new CommerceException("Region was not found.", "region_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
var batch = new CodeBatch
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
RegionId = command.RegionId,
|
||||
CreatedBy = actor.UserId,
|
||||
Name = command.Name.Trim(),
|
||||
SaleType = command.SaleType?.Trim(),
|
||||
Channel = command.Channel?.Trim(),
|
||||
DefaultUnitPriceCents = command.DefaultUnitPriceCents ?? 0,
|
||||
CostPriceCents = command.CostPriceCents ?? 0,
|
||||
TotalCount = command.TotalCount,
|
||||
Days = command.Days,
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
Remark = command.Remark
|
||||
};
|
||||
dbContext.CodeBatches.Add(batch);
|
||||
for (var index = 0; index < command.TotalCount; index++)
|
||||
{
|
||||
dbContext.ActivationCodes.Add(new ActivationCode
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
BatchId = batch.Id,
|
||||
Code = GenerateActivationCode(),
|
||||
Days = command.Days,
|
||||
SaleType = batch.SaleType,
|
||||
UnitPriceCents = batch.DefaultUnitPriceCents,
|
||||
Remark = batch.Remark
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToCodeBatchItem(batch);
|
||||
}
|
||||
|
||||
public async Task<ActivationCodeList> GetActivationCodesAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var codes = dbContext.ActivationCodes.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
var used = string.Equals(query.Status, "used", StringComparison.OrdinalIgnoreCase);
|
||||
codes = codes.Where(item => item.IsUsed == used);
|
||||
}
|
||||
|
||||
var items = await codes
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new ActivationCodeList(items.Select(ToActivationCodeItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<ActivationCodeItem> RedeemActivationCodeAsync(
|
||||
CommerceAdminActor actor,
|
||||
RedeemActivationCodeCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var code = await dbContext.ActivationCodes
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Code == command.Code.Trim(),
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Activation code was not found.", "activation_code_not_found");
|
||||
if (code.IsUsed)
|
||||
{
|
||||
throw new CommerceException("Activation code has already been used.", "activation_code_used");
|
||||
}
|
||||
|
||||
var userIsMember = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == command.UserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!userIsMember)
|
||||
{
|
||||
throw new CommerceException("Target user is not a tenant member.", "tenant_member_not_found");
|
||||
}
|
||||
|
||||
code.IsUsed = true;
|
||||
code.UsedBy = command.UserId;
|
||||
code.UsedRegionId = command.RegionId;
|
||||
code.UsedAt = DateTimeOffset.UtcNow;
|
||||
dbContext.Entitlements.Add(new Entitlement
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = command.UserId,
|
||||
EntitlementType = "svip",
|
||||
SourceType = "activation_code",
|
||||
SourceId = code.Id,
|
||||
StartsAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(code.Days),
|
||||
Status = EntitlementStatus.Active,
|
||||
Metadata = JsonSerializer.SerializeToElement(new { code.Code, code.BatchId })
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToActivationCodeItem(code);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
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 backgroundJobQueue.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 backgroundJobOperations.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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,992 +10,11 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed class CommerceService(
|
||||
public sealed partial class CommerceService(
|
||||
TikuDbContext dbContext,
|
||||
IPaymentProviderGateway paymentGateway) : ICommerceService
|
||||
{
|
||||
private sealed record CouponApplication(Coupon Coupon, CouponRedemption Redemption, int DiscountCents);
|
||||
|
||||
public async Task<CommerceOrderItem> CreateOrderAsync(
|
||||
CommerceActor actor,
|
||||
CreateCommerceOrderCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command.Quantity is < 1 or > 99)
|
||||
{
|
||||
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
|
||||
}
|
||||
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var plan = await dbContext.SvipPlans
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
|
||||
if (plan.CouponOnly &&
|
||||
string.IsNullOrWhiteSpace(command.CouponCode) &&
|
||||
!command.CouponRedemptionId.HasValue)
|
||||
{
|
||||
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
||||
}
|
||||
|
||||
if (command.RegionId.HasValue)
|
||||
{
|
||||
var regionExists = await dbContext.Regions
|
||||
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken);
|
||||
if (!regionExists)
|
||||
{
|
||||
throw new CommerceException("Region was not found.", "region_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
await using var transaction = dbContext.Database.IsRelational()
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var originalAmountCents = checked(plan.PriceCents * command.Quantity);
|
||||
var coupon = await ApplyCouponForOrderAsync(
|
||||
actor,
|
||||
command,
|
||||
plan,
|
||||
originalAmountCents,
|
||||
cancellationToken);
|
||||
if (plan.CouponOnly && coupon is null)
|
||||
{
|
||||
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
||||
}
|
||||
|
||||
var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0));
|
||||
var order = new Order
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
PlanId = plan.Id,
|
||||
RegionId = command.RegionId ?? plan.RegionId,
|
||||
OrderNo = GenerateOrderNo(),
|
||||
Status = OrderStatus.Pending,
|
||||
ProductType = "svip",
|
||||
ProductName = plan.Name,
|
||||
AmountCents = amountCents,
|
||||
PayMethod = NormalizeMethod(command.PayMethod),
|
||||
PayProvider = NormalizeProvider(command.PayProvider),
|
||||
Days = checked(plan.Days * command.Quantity),
|
||||
RawPayload = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
source = "student_checkout",
|
||||
command.Quantity,
|
||||
requestedCouponCode = command.CouponCode,
|
||||
requestedCouponRedemptionId = command.CouponRedemptionId,
|
||||
plan.PriceCents,
|
||||
plan.OriginalPriceCents,
|
||||
originalAmountCents,
|
||||
discountCents = coupon?.DiscountCents ?? 0,
|
||||
couponId = coupon?.Coupon.Id,
|
||||
couponCode = coupon?.Coupon.Code,
|
||||
couponRedemptionId = coupon?.Redemption.Id
|
||||
})
|
||||
};
|
||||
dbContext.Orders.Add(order);
|
||||
dbContext.OrderItems.Add(new OrderItem
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
ItemType = "svip_plan",
|
||||
ItemId = plan.Id,
|
||||
Name = plan.Name,
|
||||
Quantity = command.Quantity,
|
||||
UnitAmountCents = plan.PriceCents,
|
||||
TotalAmountCents = originalAmountCents,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
plan.Days,
|
||||
plan.RegionId,
|
||||
plan.VpProductId
|
||||
})
|
||||
});
|
||||
|
||||
if (coupon is not null)
|
||||
{
|
||||
coupon.Redemption.Status = CouponRedemptionStatus.Used;
|
||||
coupon.Redemption.OrderId = order.Id;
|
||||
coupon.Redemption.DiscountAppliedCents = coupon.DiscountCents;
|
||||
coupon.Redemption.UsedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (amountCents == 0)
|
||||
{
|
||||
var payment = new Payment
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = "manual",
|
||||
Method = "zero_amount",
|
||||
Status = PaymentStatus.Pending,
|
||||
AmountCents = 0
|
||||
};
|
||||
dbContext.Payments.Add(payment);
|
||||
await MarkPaidAsync(
|
||||
actor,
|
||||
order,
|
||||
payment,
|
||||
$"zero-{order.OrderNo}",
|
||||
order.RawPayload,
|
||||
"zero_amount_paid",
|
||||
$"zero-{order.OrderNo}",
|
||||
true,
|
||||
DateTimeOffset.UtcNow,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return ToOrderItem(order);
|
||||
}
|
||||
|
||||
public async Task<CommerceOrderList> GetOrdersAsync(
|
||||
CommerceActor actor,
|
||||
CommerceOrderQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var orders = dbContext.Orders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 20, 1, 100))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CommerceOrderList(items.Select(ToOrderItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CommerceOrderItem> GetOrderAsync(
|
||||
CommerceActor actor,
|
||||
string orderNo,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var order = await FindActorOrderAsync(actor, orderNo, cancellationToken);
|
||||
return ToOrderItem(order);
|
||||
}
|
||||
|
||||
public async Task<CommercePaymentItem> CreatePaymentAsync(
|
||||
CommerceActor actor,
|
||||
CreateCommercePaymentCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken);
|
||||
if (order.Status != OrderStatus.Pending)
|
||||
{
|
||||
throw new CommerceException("Only pending orders can create payments.", "order_status_invalid");
|
||||
}
|
||||
|
||||
var provider = NormalizeProvider(command.Provider);
|
||||
var method = NormalizeMethod(command.Method);
|
||||
var payment = await dbContext.Payments
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.OrderId == order.Id &&
|
||||
item.Provider == provider &&
|
||||
item.Status == PaymentStatus.Pending)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (payment is null)
|
||||
{
|
||||
payment = new Payment
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = provider,
|
||||
Method = method,
|
||||
Status = PaymentStatus.Pending,
|
||||
AmountCents = order.AmountCents
|
||||
};
|
||||
dbContext.Payments.Add(payment);
|
||||
}
|
||||
|
||||
var result = await paymentGateway.CreatePaymentAsync(
|
||||
provider,
|
||||
new CreatePaymentProviderRequest(
|
||||
actor.TenantId,
|
||||
order.OrderNo,
|
||||
order.ProductName ?? order.OrderNo,
|
||||
order.AmountCents,
|
||||
method,
|
||||
command.OpenId,
|
||||
command.ReturnUrl,
|
||||
command.QuitUrl,
|
||||
$"/api/commerce/payments/notify/{provider.Replace("_", "-", StringComparison.Ordinal)}?tenantId={actor.TenantId}",
|
||||
JsonSerializer.SerializeToElement(new { order.Id, actor.UserId })),
|
||||
cancellationToken);
|
||||
|
||||
payment.Method = result.Method;
|
||||
payment.RawPayload = result.RawPayload;
|
||||
if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo))
|
||||
{
|
||||
payment.ProviderTradeNo = result.ProviderTradeNo;
|
||||
}
|
||||
|
||||
if (IsPaid(result.Status))
|
||||
{
|
||||
await MarkPaidAsync(
|
||||
actor,
|
||||
order,
|
||||
payment,
|
||||
result.ProviderTradeNo,
|
||||
result.RawPayload,
|
||||
"payment_paid",
|
||||
result.ProviderTradeNo,
|
||||
true,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
PaymentId = payment.Id,
|
||||
Provider = provider,
|
||||
EventType = "payment_created",
|
||||
Payload = result.RawPayload
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToPaymentItem(payment, order.OrderNo, result.ClientPayload);
|
||||
}
|
||||
|
||||
public async Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
|
||||
CommerceActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entitlement = await dbContext.Entitlements
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.EntitlementType == "svip" &&
|
||||
item.Status == EntitlementStatus.Active &&
|
||||
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||
.OrderByDescending(item => item.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (entitlement is null)
|
||||
{
|
||||
return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null);
|
||||
}
|
||||
|
||||
return new CurrentEntitlementItem(
|
||||
true,
|
||||
entitlement.EntitlementType,
|
||||
entitlement.StartsAt,
|
||||
entitlement.ExpiresAt,
|
||||
entitlement.Status.ToString(),
|
||||
entitlement.ExpiresAt.HasValue
|
||||
? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays))
|
||||
: null);
|
||||
}
|
||||
|
||||
public async Task<CommerceCouponItem> ClaimCouponAsync(
|
||||
CommerceActor actor,
|
||||
ClaimCommerceCouponCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var coupon = await FindCouponByCodeAsync(actor.TenantId, command.CouponCode, cancellationToken);
|
||||
ValidateCouponClaimable(coupon, null);
|
||||
|
||||
var existing = await dbContext.CouponRedemptions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return ToCouponItem(coupon, existing, null);
|
||||
}
|
||||
|
||||
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
|
||||
var redemption = new CouponRedemption
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CouponId = coupon.Id,
|
||||
UserId = actor.UserId,
|
||||
PlanId = coupon.PlanId,
|
||||
CouponCode = coupon.Code,
|
||||
Status = CouponRedemptionStatus.Claimed,
|
||||
Source = "student_claim",
|
||||
ClaimedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
coupon.UsedCount += 1;
|
||||
dbContext.CouponRedemptions.Add(redemption);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToCouponItem(coupon, redemption, null);
|
||||
}
|
||||
|
||||
public async Task<CommerceCouponList> GetCouponsAsync(
|
||||
CommerceActor actor,
|
||||
CommerceCouponQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var redemptions = dbContext.CouponRedemptions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await redemptions
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 100))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var couponIds = items
|
||||
.Where(item => item.CouponId.HasValue)
|
||||
.Select(item => item.CouponId!.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var coupons = await dbContext.Coupons
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && couponIds.Contains(item.Id))
|
||||
.ToDictionaryAsync(item => item.Id, cancellationToken);
|
||||
|
||||
return new CommerceCouponList(
|
||||
items.Select(item =>
|
||||
{
|
||||
coupons.TryGetValue(item.CouponId ?? Guid.Empty, out var coupon);
|
||||
return ToCouponItem(coupon, item, item.DiscountAppliedCents);
|
||||
}).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CommerceCouponCheckResult> CheckCouponAsync(
|
||||
CommerceActor actor,
|
||||
CheckCommerceCouponCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
if (command.Quantity is < 1 or > 99)
|
||||
{
|
||||
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
|
||||
}
|
||||
|
||||
var plan = await dbContext.SvipPlans
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
var originalAmountCents = checked(plan.PriceCents * command.Quantity);
|
||||
try
|
||||
{
|
||||
var coupon = await ResolveCouponForCheckAsync(actor, command, plan, originalAmountCents, cancellationToken);
|
||||
return new CommerceCouponCheckResult(
|
||||
true,
|
||||
null,
|
||||
coupon.Coupon.Id,
|
||||
coupon.Redemption.Id,
|
||||
coupon.Coupon.Code,
|
||||
originalAmountCents,
|
||||
coupon.DiscountCents,
|
||||
Math.Max(0, originalAmountCents - coupon.DiscountCents),
|
||||
FormatCny(Math.Max(0, originalAmountCents - coupon.DiscountCents)));
|
||||
}
|
||||
catch (CommerceException exception) when (exception.Code.StartsWith("coupon_", StringComparison.Ordinal))
|
||||
{
|
||||
return new CommerceCouponCheckResult(
|
||||
false,
|
||||
exception.Code,
|
||||
null,
|
||||
command.CouponRedemptionId,
|
||||
command.CouponCode,
|
||||
originalAmountCents,
|
||||
0,
|
||||
originalAmountCents,
|
||||
FormatCny(originalAmountCents));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
|
||||
Guid tenantId,
|
||||
string provider,
|
||||
IReadOnlyDictionary<string, string> headers,
|
||||
string rawBody,
|
||||
JsonElement body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedProvider = NormalizeProvider(provider);
|
||||
var notification = await paymentGateway.ParsePaymentNotificationAsync(
|
||||
normalizedProvider,
|
||||
new PaymentNotificationRequest(
|
||||
tenantId,
|
||||
normalizedProvider,
|
||||
headers,
|
||||
rawBody,
|
||||
body),
|
||||
cancellationToken);
|
||||
|
||||
if (!notification.SignatureValid)
|
||||
{
|
||||
throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid");
|
||||
}
|
||||
|
||||
var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync(
|
||||
item =>
|
||||
item.Provider == normalizedProvider &&
|
||||
item.EventId == notification.EventId &&
|
||||
item.ProcessedAt != null,
|
||||
cancellationToken);
|
||||
if (alreadyProcessed)
|
||||
{
|
||||
return new PaymentNotificationProcessResult(
|
||||
normalizedProvider,
|
||||
notification.EventId,
|
||||
notification.OrderNo,
|
||||
"processed",
|
||||
true);
|
||||
}
|
||||
|
||||
var order = await dbContext.Orders
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.OrderNo == notification.OrderNo,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
|
||||
if (order.UserId is null)
|
||||
{
|
||||
throw new CommerceException("Order does not belong to a user.", "order_user_missing");
|
||||
}
|
||||
|
||||
if (order.AmountCents != notification.AmountCents)
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Provider = normalizedProvider,
|
||||
EventType = notification.EventType,
|
||||
EventId = notification.EventId,
|
||||
SignatureValid = true,
|
||||
Payload = notification.RawPayload,
|
||||
Error = "payment_amount_mismatch"
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch");
|
||||
}
|
||||
|
||||
var payment = await dbContext.Payments
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.OrderId == order.Id &&
|
||||
item.Provider == normalizedProvider)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (payment is null)
|
||||
{
|
||||
payment = new Payment
|
||||
{
|
||||
TenantId = tenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = normalizedProvider,
|
||||
Method = order.PayMethod,
|
||||
Status = PaymentStatus.Pending,
|
||||
AmountCents = order.AmountCents
|
||||
};
|
||||
dbContext.Payments.Add(payment);
|
||||
}
|
||||
|
||||
if (notification.Paid && order.Status == OrderStatus.Pending)
|
||||
{
|
||||
await MarkPaidAsync(
|
||||
new CommerceActor(tenantId, order.UserId.Value),
|
||||
order,
|
||||
payment,
|
||||
notification.ProviderTradeNo,
|
||||
notification.RawPayload,
|
||||
notification.EventType,
|
||||
notification.EventId,
|
||||
notification.SignatureValid,
|
||||
notification.PaidAt,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PaymentId = payment.Id,
|
||||
Provider = normalizedProvider,
|
||||
EventType = notification.EventType,
|
||||
EventId = notification.EventId,
|
||||
SignatureValid = notification.SignatureValid,
|
||||
Payload = notification.RawPayload,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PaymentNotificationProcessResult(
|
||||
normalizedProvider,
|
||||
notification.EventId,
|
||||
notification.OrderNo,
|
||||
"processed",
|
||||
false);
|
||||
}
|
||||
|
||||
private async Task MarkPaidAsync(
|
||||
CommerceActor actor,
|
||||
Order order,
|
||||
Payment payment,
|
||||
string? providerTradeNo,
|
||||
JsonElement rawPayload,
|
||||
string eventType,
|
||||
string? eventId,
|
||||
bool signatureValid,
|
||||
DateTimeOffset? paidAtOverride,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow;
|
||||
payment.Status = PaymentStatus.Paid;
|
||||
payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo;
|
||||
payment.PaidAt = paidAt;
|
||||
order.Status = OrderStatus.Paid;
|
||||
order.TradeNo = payment.ProviderTradeNo;
|
||||
order.PaidAt = paidAt;
|
||||
|
||||
var days = Math.Max(order.Days ?? 0, 0);
|
||||
var current = await dbContext.Entitlements
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.EntitlementType == "svip" &&
|
||||
item.Status == EntitlementStatus.Active)
|
||||
.OrderByDescending(item => item.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (current is null)
|
||||
{
|
||||
dbContext.Entitlements.Add(new Entitlement
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
EntitlementType = "svip",
|
||||
ScopeType = EntitlementScopeType.Tenant,
|
||||
SourceType = "order",
|
||||
SourceId = order.Id,
|
||||
StartsAt = paidAt,
|
||||
ExpiresAt = days > 0 ? paidAt.AddDays(days) : null,
|
||||
Status = EntitlementStatus.Active,
|
||||
Metadata = rawPayload
|
||||
});
|
||||
}
|
||||
else if (days > 0)
|
||||
{
|
||||
var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > paidAt
|
||||
? current.ExpiresAt.Value
|
||||
: paidAt;
|
||||
current.ExpiresAt = baseAt.AddDays(days);
|
||||
current.Metadata = rawPayload;
|
||||
}
|
||||
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
PaymentId = payment.Id,
|
||||
Provider = payment.Provider,
|
||||
EventType = eventType,
|
||||
EventId = eventId,
|
||||
SignatureValid = signatureValid,
|
||||
Payload = rawPayload,
|
||||
ProcessedAt = paidAt
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<CouponApplication?> ApplyCouponForOrderAsync(
|
||||
CommerceActor actor,
|
||||
CreateCommerceOrderCommand command,
|
||||
SvipPlan plan,
|
||||
int originalAmountCents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var coupon = command.CouponRedemptionId.HasValue
|
||||
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
|
||||
: await ResolveOrClaimCouponByCodeAsync(actor, command.CouponCode, cancellationToken);
|
||||
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
|
||||
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveCouponForCheckAsync(
|
||||
CommerceActor actor,
|
||||
CheckCommerceCouponCommand command,
|
||||
SvipPlan plan,
|
||||
int originalAmountCents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
|
||||
{
|
||||
throw new CommerceException("Coupon code or redemption id is required.", "coupon_required");
|
||||
}
|
||||
|
||||
var coupon = command.CouponRedemptionId.HasValue
|
||||
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
|
||||
: await ResolveCouponByCodeForCheckAsync(actor, command.CouponCode, cancellationToken);
|
||||
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
|
||||
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveOrClaimCouponByCodeAsync(
|
||||
CommerceActor actor,
|
||||
string? couponCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
|
||||
ValidateCouponClaimable(coupon, null);
|
||||
var existing = await dbContext.CouponRedemptions
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return new CouponApplication(coupon, existing, 0);
|
||||
}
|
||||
|
||||
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
|
||||
var redemption = new CouponRedemption
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CouponId = coupon.Id,
|
||||
UserId = actor.UserId,
|
||||
PlanId = coupon.PlanId,
|
||||
CouponCode = coupon.Code,
|
||||
Status = CouponRedemptionStatus.Claimed,
|
||||
Source = "checkout_claim",
|
||||
ClaimedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
coupon.UsedCount += 1;
|
||||
dbContext.CouponRedemptions.Add(redemption);
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveCouponByCodeForCheckAsync(
|
||||
CommerceActor actor,
|
||||
string? couponCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
|
||||
var redemption = await dbContext.CouponRedemptions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (redemption is not null)
|
||||
{
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
|
||||
ValidateCouponClaimable(coupon, null);
|
||||
return new CouponApplication(
|
||||
coupon,
|
||||
new CouponRedemption
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
TenantId = actor.TenantId,
|
||||
CouponId = coupon.Id,
|
||||
UserId = actor.UserId,
|
||||
PlanId = coupon.PlanId,
|
||||
CouponCode = coupon.Code,
|
||||
Status = CouponRedemptionStatus.Claimed
|
||||
},
|
||||
0);
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveCouponByRedemptionAsync(
|
||||
CommerceActor actor,
|
||||
Guid couponRedemptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var redemption = await dbContext.CouponRedemptions
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Id == couponRedemptionId,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Coupon redemption was not found.", "coupon_redemption_not_found");
|
||||
if (redemption.CouponId is null)
|
||||
{
|
||||
throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid");
|
||||
}
|
||||
|
||||
var coupon = await dbContext.Coupons
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
|
||||
private async Task<Coupon> FindCouponByCodeAsync(
|
||||
Guid tenantId,
|
||||
string? couponCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var code = NormalizeRequired(couponCode, "coupon_code_required");
|
||||
return await dbContext.Coupons
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
}
|
||||
|
||||
private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (coupon.ValidFrom is not null && coupon.ValidFrom > now ||
|
||||
coupon.ValidTo is not null && coupon.ValidTo <= now)
|
||||
{
|
||||
throw new CommerceException("Coupon is expired or not started.", "coupon_inactive");
|
||||
}
|
||||
|
||||
if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCouponUsable(
|
||||
Coupon coupon,
|
||||
CouponRedemption redemption,
|
||||
SvipPlan plan,
|
||||
Guid? regionId)
|
||||
{
|
||||
ValidateCouponClaimable(coupon, redemption);
|
||||
if (redemption.Status != CouponRedemptionStatus.Claimed)
|
||||
{
|
||||
throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid");
|
||||
}
|
||||
|
||||
if (coupon.PlanId.HasValue && coupon.PlanId != plan.Id ||
|
||||
redemption.PlanId.HasValue && redemption.PlanId != plan.Id)
|
||||
{
|
||||
throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable");
|
||||
}
|
||||
|
||||
if (redemption.RegionId.HasValue &&
|
||||
regionId.HasValue &&
|
||||
redemption.RegionId != regionId)
|
||||
{
|
||||
throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable");
|
||||
}
|
||||
}
|
||||
|
||||
private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents)
|
||||
{
|
||||
var discount = coupon.DiscountType switch
|
||||
{
|
||||
DiscountType.Fixed => (int)Math.Round((coupon.DiscountValue ?? 0) * 100, MidpointRounding.AwayFromZero),
|
||||
DiscountType.Percent => (int)Math.Round(
|
||||
originalAmountCents * PercentFactor(coupon.DiscountValue ?? 0),
|
||||
MidpointRounding.AwayFromZero),
|
||||
_ => 0
|
||||
};
|
||||
return Math.Clamp(discount, 0, originalAmountCents);
|
||||
}
|
||||
|
||||
private static decimal PercentFactor(decimal value)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return value <= 1 ? value : value / 100;
|
||||
}
|
||||
|
||||
private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Order> FindActorOrderAsync(
|
||||
CommerceActor actor,
|
||||
string orderNo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var trimmed = orderNo.Trim();
|
||||
return await dbContext.Orders
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.OrderNo == trimmed,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
}
|
||||
|
||||
private static CommerceOrderItem ToOrderItem(Order order)
|
||||
{
|
||||
return new CommerceOrderItem(
|
||||
order.Id,
|
||||
order.OrderNo,
|
||||
order.Status.ToString(),
|
||||
order.PlanId,
|
||||
order.RegionId,
|
||||
order.ProductType,
|
||||
order.ProductName,
|
||||
order.AmountCents,
|
||||
FormatCny(order.AmountCents),
|
||||
order.PayMethod,
|
||||
order.PayProvider,
|
||||
order.TradeNo,
|
||||
order.Days,
|
||||
order.PaidAt,
|
||||
order.CreatedAt,
|
||||
order.RawPayload);
|
||||
}
|
||||
|
||||
private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload)
|
||||
{
|
||||
return new CommercePaymentItem(
|
||||
payment.Id,
|
||||
payment.OrderId,
|
||||
orderNo,
|
||||
payment.Provider,
|
||||
payment.Method,
|
||||
payment.Status.ToString(),
|
||||
payment.AmountCents,
|
||||
FormatCny(payment.AmountCents),
|
||||
payment.ProviderTradeNo,
|
||||
payment.PaidAt,
|
||||
clientPayload,
|
||||
payment.RawPayload);
|
||||
}
|
||||
|
||||
private static CommerceCouponItem ToCouponItem(
|
||||
Coupon? coupon,
|
||||
CouponRedemption redemption,
|
||||
int? discountPreviewCents)
|
||||
{
|
||||
return new CommerceCouponItem(
|
||||
redemption.Id,
|
||||
redemption.CouponId,
|
||||
redemption.CouponCode ?? coupon?.Code ?? string.Empty,
|
||||
redemption.Status.ToString(),
|
||||
redemption.PlanId ?? coupon?.PlanId,
|
||||
redemption.RegionId,
|
||||
coupon?.DiscountType?.ToString(),
|
||||
coupon?.DiscountValue,
|
||||
discountPreviewCents,
|
||||
discountPreviewCents.HasValue ? FormatCny(discountPreviewCents.Value) : null,
|
||||
coupon?.ValidFrom,
|
||||
coupon?.ValidTo,
|
||||
redemption.ClaimedAt,
|
||||
redemption.UsedAt);
|
||||
}
|
||||
|
||||
private static OrderStatus ParseOrderStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
|
||||
}
|
||||
|
||||
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status");
|
||||
}
|
||||
|
||||
private static string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(
|
||||
['_', '-', ' '],
|
||||
StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
private static string NormalizeRequired(string? value, string code)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
return !string.IsNullOrWhiteSpace(trimmed)
|
||||
? trimmed
|
||||
: throw new CommerceException("Required commerce value is missing.", code);
|
||||
}
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
{
|
||||
var normalized = (provider ?? PaymentProviders.Manual)
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Replace("-", "_", StringComparison.Ordinal);
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"" => PaymentProviders.Manual,
|
||||
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
|
||||
"ali_pay" => PaymentProviders.Alipay,
|
||||
PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized,
|
||||
_ => throw new CommerceException("Payment provider is invalid.", "invalid_payment_provider")
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeMethod(string? method)
|
||||
{
|
||||
var normalized = (method ?? "manual").Trim().ToLowerInvariant();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized;
|
||||
}
|
||||
|
||||
private static bool IsPaid(string status) =>
|
||||
string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string GenerateOrderNo()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[4];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}";
|
||||
}
|
||||
|
||||
private static string FormatCny(int cents) =>
|
||||
(cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
public async Task<TenantCouponList> GetCouponsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var coupons = dbContext.Coupons.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
var items = await coupons
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantCouponList(items);
|
||||
}
|
||||
|
||||
public async Task<Coupon> UpsertCouponAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertCouponCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var coupon = command.Id.HasValue
|
||||
? await dbContext.Coupons.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: await dbContext.Coupons.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Code == command.Code.Trim(),
|
||||
cancellationToken);
|
||||
if (coupon is null)
|
||||
{
|
||||
coupon = new Coupon { TenantId = actor.TenantId };
|
||||
dbContext.Coupons.Add(coupon);
|
||||
}
|
||||
|
||||
coupon.Code = command.Code.Trim();
|
||||
coupon.PlanId = command.PlanId;
|
||||
coupon.DiscountType = command.DiscountType;
|
||||
coupon.DiscountValue = command.DiscountValue;
|
||||
coupon.ValidFrom = command.ValidFrom;
|
||||
coupon.ValidTo = command.ValidTo;
|
||||
coupon.MaxUses = command.MaxUses;
|
||||
coupon.Source = command.Source?.Trim();
|
||||
coupon.Remark = command.Remark;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return coupon;
|
||||
}
|
||||
|
||||
public async Task<TenantCouponRedemptionList> GetCouponRedemptionsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await redemptions
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantCouponRedemptionList(items);
|
||||
}
|
||||
|
||||
public async Task<TenantCouponReport> GetCouponReportAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var couponCount = await dbContext.Coupons.CountAsync(item => item.TenantId == actor.TenantId, cancellationToken);
|
||||
var redemptions = dbContext.CouponRedemptions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
var claimedCount = await redemptions.CountAsync(cancellationToken);
|
||||
var usedCount = await redemptions.CountAsync(item => item.Status == CouponRedemptionStatus.Used, cancellationToken);
|
||||
var discountApplied = await redemptions
|
||||
.Where(item => item.Status == CouponRedemptionStatus.Used)
|
||||
.SumAsync(item => item.DiscountAppliedCents, cancellationToken) ?? 0;
|
||||
return new TenantCouponReport(couponCount, claimedCount, usedCount, discountApplied);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
145
Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs
Normal file
145
Tiku.Infrastructure/Commerce/Coupons/CommerceService.Coupons.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed partial class CommerceService
|
||||
{
|
||||
public async Task<CommerceCouponItem> ClaimCouponAsync(
|
||||
CommerceActor actor,
|
||||
ClaimCommerceCouponCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var coupon = await FindCouponByCodeAsync(actor.TenantId, command.CouponCode, cancellationToken);
|
||||
ValidateCouponClaimable(coupon, null);
|
||||
|
||||
var existing = await dbContext.CouponRedemptions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return ToCouponItem(coupon, existing, null);
|
||||
}
|
||||
|
||||
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
|
||||
var redemption = new CouponRedemption
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CouponId = coupon.Id,
|
||||
UserId = actor.UserId,
|
||||
PlanId = coupon.PlanId,
|
||||
CouponCode = coupon.Code,
|
||||
Status = CouponRedemptionStatus.Claimed,
|
||||
Source = "student_claim",
|
||||
ClaimedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
coupon.UsedCount += 1;
|
||||
dbContext.CouponRedemptions.Add(redemption);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToCouponItem(coupon, redemption, null);
|
||||
}
|
||||
|
||||
public async Task<CommerceCouponList> GetCouponsAsync(
|
||||
CommerceActor actor,
|
||||
CommerceCouponQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var redemptions = dbContext.CouponRedemptions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
redemptions = redemptions.Where(item => item.Status == ParseCouponRedemptionStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await redemptions
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 100))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var couponIds = items
|
||||
.Where(item => item.CouponId.HasValue)
|
||||
.Select(item => item.CouponId!.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var coupons = await dbContext.Coupons
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && couponIds.Contains(item.Id))
|
||||
.ToDictionaryAsync(item => item.Id, cancellationToken);
|
||||
|
||||
return new CommerceCouponList(
|
||||
items.Select(item =>
|
||||
{
|
||||
coupons.TryGetValue(item.CouponId ?? Guid.Empty, out var coupon);
|
||||
return ToCouponItem(coupon, item, item.DiscountAppliedCents);
|
||||
}).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CommerceCouponCheckResult> CheckCouponAsync(
|
||||
CommerceActor actor,
|
||||
CheckCommerceCouponCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
if (command.Quantity is < 1 or > 99)
|
||||
{
|
||||
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
|
||||
}
|
||||
|
||||
var plan = await dbContext.SvipPlans
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
var originalAmountCents = checked(plan.PriceCents * command.Quantity);
|
||||
try
|
||||
{
|
||||
var coupon = await ResolveCouponForCheckAsync(actor, command, plan, originalAmountCents, cancellationToken);
|
||||
return new CommerceCouponCheckResult(
|
||||
true,
|
||||
null,
|
||||
coupon.Coupon.Id,
|
||||
coupon.Redemption.Id,
|
||||
coupon.Coupon.Code,
|
||||
originalAmountCents,
|
||||
coupon.DiscountCents,
|
||||
Math.Max(0, originalAmountCents - coupon.DiscountCents),
|
||||
FormatCny(Math.Max(0, originalAmountCents - coupon.DiscountCents)));
|
||||
}
|
||||
catch (CommerceException exception) when (exception.Code.StartsWith("coupon_", StringComparison.Ordinal))
|
||||
{
|
||||
return new CommerceCouponCheckResult(
|
||||
false,
|
||||
exception.Code,
|
||||
null,
|
||||
command.CouponRedemptionId,
|
||||
command.CouponCode,
|
||||
originalAmountCents,
|
||||
0,
|
||||
originalAmountCents,
|
||||
FormatCny(originalAmountCents));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed partial class CommerceService
|
||||
{
|
||||
public async Task<CurrentEntitlementItem> GetCurrentEntitlementAsync(
|
||||
CommerceActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entitlement = await dbContext.Entitlements
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.EntitlementType == "svip" &&
|
||||
item.Status == EntitlementStatus.Active &&
|
||||
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||
.OrderByDescending(item => item.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (entitlement is null)
|
||||
{
|
||||
return new CurrentEntitlementItem(false, "svip", null, null, "inactive", null);
|
||||
}
|
||||
|
||||
return new CurrentEntitlementItem(
|
||||
true,
|
||||
entitlement.EntitlementType,
|
||||
entitlement.StartsAt,
|
||||
entitlement.ExpiresAt,
|
||||
entitlement.Status.ToString(),
|
||||
entitlement.ExpiresAt.HasValue
|
||||
? Math.Max(0, (int)Math.Ceiling((entitlement.ExpiresAt.Value - now).TotalDays))
|
||||
: null);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
if (!access.IsCurrentTenantMember ||
|
||||
access.UserId != actor.UserId ||
|
||||
access.TenantId != actor.TenantId ||
|
||||
!access.HasTenantPermission(BackendPermissions.TenantCommerceOperate))
|
||||
{
|
||||
throw new CommerceException("Tenant admin access is required.", "tenant_admin_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CurrentDataScope> RequireDataScopeAsync(
|
||||
CommerceAdminActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
return (await currentAccessContext.GetAsync(cancellationToken)).DataScope;
|
||||
}
|
||||
|
||||
private static TenantPaymentProviderItem ToPaymentAccountItem(TenantExternalProviderItem item) =>
|
||||
new(
|
||||
item.Id,
|
||||
item.Provider,
|
||||
GetJsonString(item.ConfigPublic, "mode") ?? "TenantCollect",
|
||||
item.DisplayName,
|
||||
item.Status,
|
||||
item.SecretRef,
|
||||
item.Priority,
|
||||
item.ConfigPublic,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
|
||||
private static TenantSecretItem ToSecretItem(TenantSecret item) =>
|
||||
new(item.Id, item.Purpose, item.Provider, item.SecretKey, item.SecretRef, item.Status.ToString(), item.RotatedAt, item.ExpiresAt, item.UpdatedAt);
|
||||
|
||||
private static CodeBatchItem ToCodeBatchItem(CodeBatch item) =>
|
||||
new(item.Id, item.Name, item.TotalCount, item.Days ?? 0, item.RegionId, item.SaleType, item.Channel, item.DefaultUnitPriceCents, item.CostPriceCents, item.IssuedAt, item.Remark, item.CreatedAt);
|
||||
|
||||
private static ActivationCodeItem ToActivationCodeItem(ActivationCode item) =>
|
||||
new(item.Id, item.BatchId, item.Code, item.Days, item.IsUsed, item.UsedBy, item.UsedAt, item.SaleType, item.SoldTo, item.Remark, item.CreatedAt);
|
||||
|
||||
private static CommerceOrderItem ToOrderItem(Order order) =>
|
||||
new(order.Id, order.OrderNo, order.Status.ToString(), order.PlanId, order.RegionId, order.ProductType, order.ProductName, order.AmountCents, FormatCny(order.AmountCents), order.PayMethod, order.PayProvider, order.TradeNo, order.Days, order.PaidAt, order.CreatedAt, order.RawPayload);
|
||||
|
||||
private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo) =>
|
||||
new(payment.Id, payment.OrderId, orderNo, payment.Provider, payment.Method, payment.Status.ToString(), payment.AmountCents, FormatCny(payment.AmountCents), payment.ProviderTradeNo, payment.PaidAt, JsonSerializer.SerializeToElement(new { }), payment.RawPayload);
|
||||
|
||||
private static OrderStatus ParseOrderStatus(string? status) =>
|
||||
Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
|
||||
|
||||
private static PaymentStatus ParsePaymentStatus(string? status) =>
|
||||
Enum.TryParse<PaymentStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Payment status is invalid.", "invalid_payment_status");
|
||||
|
||||
private static PointActivityTaskStatus ParsePointTaskStatus(string? status) =>
|
||||
Enum.TryParse<PointActivityTaskStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point task status is invalid.", "invalid_point_task_status");
|
||||
|
||||
private static PointExchangeItemStatus ParsePointExchangeItemStatus(string? status) =>
|
||||
Enum.TryParse<PointExchangeItemStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point exchange item status is invalid.", "invalid_point_exchange_item_status");
|
||||
|
||||
private static PointExchangeOrderStatus ParsePointExchangeOrderStatus(string? status) =>
|
||||
Enum.TryParse<PointExchangeOrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Point exchange order status is invalid.", "invalid_point_exchange_order_status");
|
||||
|
||||
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status) =>
|
||||
Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Coupon redemption status is invalid.", "invalid_coupon_redemption_status");
|
||||
|
||||
private static CommerceRefundStatus ParseRefundStatus(string? status) =>
|
||||
Enum.TryParse<CommerceRefundStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Refund status is invalid.", "invalid_refund_status");
|
||||
|
||||
private static ReconciliationBatchStatus ParseReconciliationBatchStatus(string? status) =>
|
||||
Enum.TryParse<ReconciliationBatchStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Reconciliation batch status is invalid.", "invalid_reconciliation_batch_status");
|
||||
|
||||
private static ReconciliationIssueStatus ParseReconciliationIssueStatus(string? status) =>
|
||||
Enum.TryParse<ReconciliationIssueStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? 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(
|
||||
item => item.TenantId == refund.TenantId && item.Id == refund.OrderId,
|
||||
cancellationToken);
|
||||
if (order.RefundedAmountCents < order.AmountCents)
|
||||
{
|
||||
order.RefundedAmountCents = Math.Min(order.AmountCents, order.RefundedAmountCents + refund.AmountCents);
|
||||
order.Status = order.RefundedAmountCents >= order.AmountCents
|
||||
? OrderStatus.Refunded
|
||||
: OrderStatus.PartiallyRefunded;
|
||||
}
|
||||
|
||||
if (refund.PaymentId.HasValue)
|
||||
{
|
||||
var payment = await dbContext.Payments.SingleOrDefaultAsync(
|
||||
item => item.TenantId == refund.TenantId && item.Id == refund.PaymentId.Value,
|
||||
cancellationToken);
|
||||
if (payment is not null)
|
||||
{
|
||||
payment.RefundedAmountCents = Math.Min(payment.AmountCents, payment.RefundedAmountCents + refund.AmountCents);
|
||||
payment.Status = payment.RefundedAmountCents >= payment.AmountCents
|
||||
? PaymentStatus.Refunded
|
||||
: PaymentStatus.PartiallyRefunded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
|
||||
{
|
||||
return from switch
|
||||
{
|
||||
CommerceRefundStatus.Requested => to is CommerceRefundStatus.Approved or CommerceRefundStatus.Rejected or CommerceRefundStatus.Cancelled,
|
||||
CommerceRefundStatus.Approved => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
||||
CommerceRefundStatus.Processing => to is CommerceRefundStatus.Succeeded or CommerceRefundStatus.Failed,
|
||||
CommerceRefundStatus.Failed => to is CommerceRefundStatus.Processing or CommerceRefundStatus.Cancelled,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
CommerceRefundStatus toStatus,
|
||||
string eventType,
|
||||
Guid actorUserId,
|
||||
object details)
|
||||
{
|
||||
dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent
|
||||
{
|
||||
TenantId = refund.TenantId,
|
||||
RefundRequestId = refund.Id,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = toStatus,
|
||||
EventType = eventType,
|
||||
ActorUserId = actorUserId,
|
||||
Details = JsonSerializer.SerializeToElement(details)
|
||||
});
|
||||
}
|
||||
|
||||
private Task AddAuditAsync(
|
||||
CommerceAdminActor actor,
|
||||
string action,
|
||||
string targetType,
|
||||
Guid targetId,
|
||||
object details,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
ActorUserId = actor.UserId,
|
||||
Action = action,
|
||||
TargetType = targetType,
|
||||
TargetId = targetId.ToString(),
|
||||
Details = JsonSerializer.SerializeToElement(details)
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
{
|
||||
var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant().Replace("-", "_", StringComparison.Ordinal);
|
||||
return normalized switch
|
||||
{
|
||||
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
|
||||
"ali_pay" => PaymentProviders.Alipay,
|
||||
"" => throw new CommerceException("Provider is required.", "provider_required"),
|
||||
_ => normalized
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement WithPaymentMode(JsonElement element, string? mode)
|
||||
{
|
||||
var values = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
values[property.Name] = property.Value.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
values["mode"] = JsonSerializer.SerializeToElement(
|
||||
string.IsNullOrWhiteSpace(mode) ? "TenantCollect" : mode.Trim());
|
||||
return JsonSerializer.SerializeToElement(values);
|
||||
}
|
||||
|
||||
private static string? GetJsonString(JsonElement element, params string[] keys)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement element) =>
|
||||
element.ValueKind == JsonValueKind.Object
|
||||
? element.Clone()
|
||||
: JsonSerializer.SerializeToElement(new { });
|
||||
|
||||
private static void AssertNoSecrets(JsonElement element, string path)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
var key = property.Name.ToLowerInvariant();
|
||||
if (key is "secretref" or "secret_ref")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.Contains("secret", StringComparison.Ordinal) ||
|
||||
key.Contains("privatekey", StringComparison.Ordinal) ||
|
||||
key is "appsecret" or "apiv3key" or "api_v3_key" or "accesskeysecret")
|
||||
{
|
||||
throw new CommerceException($"{path} cannot contain secrets.", "public_config_contains_secret");
|
||||
}
|
||||
|
||||
AssertNoSecrets(property.Value, $"{path}.{property.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return $"TKU{Convert.ToHexString(bytes)}";
|
||||
}
|
||||
|
||||
private static string FormatCny(int cents) =>
|
||||
(cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed partial class CommerceService
|
||||
{
|
||||
private async Task MarkPaidAsync(
|
||||
CommerceActor actor,
|
||||
Order order,
|
||||
Payment payment,
|
||||
string? providerTradeNo,
|
||||
JsonElement rawPayload,
|
||||
string eventType,
|
||||
string? eventId,
|
||||
bool signatureValid,
|
||||
DateTimeOffset? paidAtOverride,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var paidAt = paidAtOverride ?? DateTimeOffset.UtcNow;
|
||||
payment.Status = PaymentStatus.Paid;
|
||||
payment.ProviderTradeNo = providerTradeNo ?? payment.ProviderTradeNo;
|
||||
payment.PaidAt = paidAt;
|
||||
order.Status = OrderStatus.Paid;
|
||||
order.TradeNo = payment.ProviderTradeNo;
|
||||
order.PaidAt = paidAt;
|
||||
|
||||
var days = Math.Max(order.Days ?? 0, 0);
|
||||
var current = await dbContext.Entitlements
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.EntitlementType == "svip" &&
|
||||
item.Status == EntitlementStatus.Active)
|
||||
.OrderByDescending(item => item.ExpiresAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (current is null)
|
||||
{
|
||||
dbContext.Entitlements.Add(new Entitlement
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
EntitlementType = "svip",
|
||||
ScopeType = EntitlementScopeType.Tenant,
|
||||
SourceType = "order",
|
||||
SourceId = order.Id,
|
||||
StartsAt = paidAt,
|
||||
ExpiresAt = days > 0 ? paidAt.AddDays(days) : null,
|
||||
Status = EntitlementStatus.Active,
|
||||
Metadata = rawPayload
|
||||
});
|
||||
}
|
||||
else if (days > 0)
|
||||
{
|
||||
var baseAt = current.ExpiresAt.HasValue && current.ExpiresAt > paidAt
|
||||
? current.ExpiresAt.Value
|
||||
: paidAt;
|
||||
current.ExpiresAt = baseAt.AddDays(days);
|
||||
current.Metadata = rawPayload;
|
||||
}
|
||||
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
PaymentId = payment.Id,
|
||||
Provider = payment.Provider,
|
||||
EventType = eventType,
|
||||
EventId = eventId,
|
||||
SignatureValid = signatureValid,
|
||||
Payload = rawPayload,
|
||||
ProcessedAt = paidAt
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<CouponApplication?> ApplyCouponForOrderAsync(
|
||||
CommerceActor actor,
|
||||
CreateCommerceOrderCommand command,
|
||||
SvipPlan plan,
|
||||
int originalAmountCents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var coupon = command.CouponRedemptionId.HasValue
|
||||
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
|
||||
: await ResolveOrClaimCouponByCodeAsync(actor, command.CouponCode, cancellationToken);
|
||||
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
|
||||
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveCouponForCheckAsync(
|
||||
CommerceActor actor,
|
||||
CheckCommerceCouponCommand command,
|
||||
SvipPlan plan,
|
||||
int originalAmountCents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (command.CouponRedemptionId is null && string.IsNullOrWhiteSpace(command.CouponCode))
|
||||
{
|
||||
throw new CommerceException("Coupon code or redemption id is required.", "coupon_required");
|
||||
}
|
||||
|
||||
var coupon = command.CouponRedemptionId.HasValue
|
||||
? await ResolveCouponByRedemptionAsync(actor, command.CouponRedemptionId.Value, cancellationToken)
|
||||
: await ResolveCouponByCodeForCheckAsync(actor, command.CouponCode, cancellationToken);
|
||||
ValidateCouponUsable(coupon.Coupon, coupon.Redemption, plan, command.RegionId);
|
||||
return coupon with { DiscountCents = CalculateDiscountCents(coupon.Coupon, originalAmountCents) };
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveOrClaimCouponByCodeAsync(
|
||||
CommerceActor actor,
|
||||
string? couponCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
|
||||
ValidateCouponClaimable(coupon, null);
|
||||
var existing = await dbContext.CouponRedemptions
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return new CouponApplication(coupon, existing, 0);
|
||||
}
|
||||
|
||||
if (coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
|
||||
var redemption = new CouponRedemption
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CouponId = coupon.Id,
|
||||
UserId = actor.UserId,
|
||||
PlanId = coupon.PlanId,
|
||||
CouponCode = coupon.Code,
|
||||
Status = CouponRedemptionStatus.Claimed,
|
||||
Source = "checkout_claim",
|
||||
ClaimedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
coupon.UsedCount += 1;
|
||||
dbContext.CouponRedemptions.Add(redemption);
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveCouponByCodeForCheckAsync(
|
||||
CommerceActor actor,
|
||||
string? couponCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var coupon = await FindCouponByCodeAsync(actor.TenantId, couponCode, cancellationToken);
|
||||
var redemption = await dbContext.CouponRedemptions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.CouponId == coupon.Id)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (redemption is not null)
|
||||
{
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
|
||||
ValidateCouponClaimable(coupon, null);
|
||||
return new CouponApplication(
|
||||
coupon,
|
||||
new CouponRedemption
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
TenantId = actor.TenantId,
|
||||
CouponId = coupon.Id,
|
||||
UserId = actor.UserId,
|
||||
PlanId = coupon.PlanId,
|
||||
CouponCode = coupon.Code,
|
||||
Status = CouponRedemptionStatus.Claimed
|
||||
},
|
||||
0);
|
||||
}
|
||||
|
||||
private async Task<CouponApplication> ResolveCouponByRedemptionAsync(
|
||||
CommerceActor actor,
|
||||
Guid couponRedemptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var redemption = await dbContext.CouponRedemptions
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Id == couponRedemptionId,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Coupon redemption was not found.", "coupon_redemption_not_found");
|
||||
if (redemption.CouponId is null)
|
||||
{
|
||||
throw new CommerceException("Coupon redemption is not linked to a coupon.", "coupon_redemption_invalid");
|
||||
}
|
||||
|
||||
var coupon = await dbContext.Coupons
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == redemption.CouponId.Value, cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
return new CouponApplication(coupon, redemption, 0);
|
||||
}
|
||||
|
||||
private async Task<Coupon> FindCouponByCodeAsync(
|
||||
Guid tenantId,
|
||||
string? couponCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var code = NormalizeRequired(couponCode, "coupon_code_required");
|
||||
return await dbContext.Coupons
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Code == code, cancellationToken)
|
||||
?? throw new CommerceException("Coupon was not found.", "coupon_not_found");
|
||||
}
|
||||
|
||||
private static void ValidateCouponClaimable(Coupon coupon, CouponRedemption? redemption)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (coupon.ValidFrom is not null && coupon.ValidFrom > now ||
|
||||
coupon.ValidTo is not null && coupon.ValidTo <= now)
|
||||
{
|
||||
throw new CommerceException("Coupon is expired or not started.", "coupon_inactive");
|
||||
}
|
||||
|
||||
if (redemption is null && coupon.MaxUses.HasValue && coupon.UsedCount >= coupon.MaxUses.Value)
|
||||
{
|
||||
throw new CommerceException("Coupon usage limit has been reached.", "coupon_usage_limit_reached");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCouponUsable(
|
||||
Coupon coupon,
|
||||
CouponRedemption redemption,
|
||||
SvipPlan plan,
|
||||
Guid? regionId)
|
||||
{
|
||||
ValidateCouponClaimable(coupon, redemption);
|
||||
if (redemption.Status != CouponRedemptionStatus.Claimed)
|
||||
{
|
||||
throw new CommerceException("Coupon redemption is not claimable.", "coupon_redemption_status_invalid");
|
||||
}
|
||||
|
||||
if (coupon.PlanId.HasValue && coupon.PlanId != plan.Id ||
|
||||
redemption.PlanId.HasValue && redemption.PlanId != plan.Id)
|
||||
{
|
||||
throw new CommerceException("Coupon is not applicable to this plan.", "coupon_plan_not_applicable");
|
||||
}
|
||||
|
||||
if (redemption.RegionId.HasValue &&
|
||||
regionId.HasValue &&
|
||||
redemption.RegionId != regionId)
|
||||
{
|
||||
throw new CommerceException("Coupon is not applicable to this region.", "coupon_region_not_applicable");
|
||||
}
|
||||
}
|
||||
|
||||
private static int CalculateDiscountCents(Coupon coupon, int originalAmountCents)
|
||||
{
|
||||
var discount = coupon.DiscountType switch
|
||||
{
|
||||
DiscountType.Fixed => (int)Math.Round((coupon.DiscountValue ?? 0) * 100, MidpointRounding.AwayFromZero),
|
||||
DiscountType.Percent => (int)Math.Round(
|
||||
originalAmountCents * PercentFactor(coupon.DiscountValue ?? 0),
|
||||
MidpointRounding.AwayFromZero),
|
||||
_ => 0
|
||||
};
|
||||
return Math.Clamp(discount, 0, originalAmountCents);
|
||||
}
|
||||
|
||||
private static decimal PercentFactor(decimal value)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return value <= 1 ? value : value / 100;
|
||||
}
|
||||
|
||||
private async Task AssertActiveMemberAsync(CommerceActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new CommerceException("Current user is not a member of the tenant.", "tenant_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Order> FindActorOrderAsync(
|
||||
CommerceActor actor,
|
||||
string orderNo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var trimmed = orderNo.Trim();
|
||||
return await dbContext.Orders
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.OrderNo == trimmed,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
}
|
||||
|
||||
private static CommerceOrderItem ToOrderItem(Order order)
|
||||
{
|
||||
return new CommerceOrderItem(
|
||||
order.Id,
|
||||
order.OrderNo,
|
||||
order.Status.ToString(),
|
||||
order.PlanId,
|
||||
order.RegionId,
|
||||
order.ProductType,
|
||||
order.ProductName,
|
||||
order.AmountCents,
|
||||
FormatCny(order.AmountCents),
|
||||
order.PayMethod,
|
||||
order.PayProvider,
|
||||
order.TradeNo,
|
||||
order.Days,
|
||||
order.PaidAt,
|
||||
order.CreatedAt,
|
||||
order.RawPayload);
|
||||
}
|
||||
|
||||
private static CommercePaymentItem ToPaymentItem(Payment payment, string orderNo, JsonElement clientPayload)
|
||||
{
|
||||
return new CommercePaymentItem(
|
||||
payment.Id,
|
||||
payment.OrderId,
|
||||
orderNo,
|
||||
payment.Provider,
|
||||
payment.Method,
|
||||
payment.Status.ToString(),
|
||||
payment.AmountCents,
|
||||
FormatCny(payment.AmountCents),
|
||||
payment.ProviderTradeNo,
|
||||
payment.PaidAt,
|
||||
clientPayload,
|
||||
payment.RawPayload);
|
||||
}
|
||||
|
||||
private static CommerceCouponItem ToCouponItem(
|
||||
Coupon? coupon,
|
||||
CouponRedemption redemption,
|
||||
int? discountPreviewCents)
|
||||
{
|
||||
return new CommerceCouponItem(
|
||||
redemption.Id,
|
||||
redemption.CouponId,
|
||||
redemption.CouponCode ?? coupon?.Code ?? string.Empty,
|
||||
redemption.Status.ToString(),
|
||||
redemption.PlanId ?? coupon?.PlanId,
|
||||
redemption.RegionId,
|
||||
coupon?.DiscountType?.ToString(),
|
||||
coupon?.DiscountValue,
|
||||
discountPreviewCents,
|
||||
discountPreviewCents.HasValue ? FormatCny(discountPreviewCents.Value) : null,
|
||||
coupon?.ValidFrom,
|
||||
coupon?.ValidTo,
|
||||
redemption.ClaimedAt,
|
||||
redemption.UsedAt);
|
||||
}
|
||||
|
||||
private static OrderStatus ParseOrderStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<OrderStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Order status is invalid.", "invalid_order_status");
|
||||
}
|
||||
|
||||
private static CouponRedemptionStatus ParseCouponRedemptionStatus(string? status)
|
||||
{
|
||||
return Enum.TryParse<CouponRedemptionStatus>(NormalizeEnum(status), true, out var parsed)
|
||||
? parsed
|
||||
: throw new CommerceException("Coupon status is invalid.", "invalid_coupon_status");
|
||||
}
|
||||
|
||||
private static string NormalizeEnum(string? value) =>
|
||||
string.Concat((value ?? string.Empty).Split(
|
||||
['_', '-', ' '],
|
||||
StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
private static string NormalizeRequired(string? value, string code)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
return !string.IsNullOrWhiteSpace(trimmed)
|
||||
? trimmed
|
||||
: throw new CommerceException("Required commerce value is missing.", code);
|
||||
}
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
{
|
||||
var normalized = (provider ?? PaymentProviders.Manual)
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Replace("-", "_", StringComparison.Ordinal);
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"" => PaymentProviders.Manual,
|
||||
"wechat" or "wechatpay" or "wxpay" or "wx_pay" => PaymentProviders.WechatPay,
|
||||
"ali_pay" => PaymentProviders.Alipay,
|
||||
PaymentProviders.WechatPay or PaymentProviders.Alipay or PaymentProviders.Manual => normalized,
|
||||
_ => throw new CommerceException("Payment provider is invalid.", "invalid_payment_provider")
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeMethod(string? method)
|
||||
{
|
||||
var normalized = (method ?? "manual").Trim().ToLowerInvariant();
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "manual" : normalized;
|
||||
}
|
||||
|
||||
private static bool IsPaid(string status) =>
|
||||
string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "success", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(status, "succeeded", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string GenerateOrderNo()
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[4];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return $"TK{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Convert.ToHexString(bytes)}";
|
||||
}
|
||||
|
||||
private static string FormatCny(int cents) =>
|
||||
(cents / 100m).ToString("0.00", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
public async Task<AdminOrderList> GetOrdersAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var orders = 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));
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new AdminOrderList(items.Select(ToOrderItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<AdminPaymentList> GetPaymentsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var scopedOrders = dbContext.Orders.AsNoTracking()
|
||||
.Where(order => order.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
order => order.UserId == actor.UserId,
|
||||
order => order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value));
|
||||
var payments = from payment in dbContext.Payments.AsNoTracking()
|
||||
join order in scopedOrders
|
||||
on new { payment.TenantId, payment.OrderId } equals new { order.TenantId, OrderId = order.Id }
|
||||
where payment.TenantId == actor.TenantId
|
||||
select new { payment, order.OrderNo };
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
var provider = NormalizeProvider(query.Provider);
|
||||
payments = payments.Where(item => item.payment.Provider == provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
payments = payments.Where(item => item.payment.Status == ParsePaymentStatus(query.Status));
|
||||
}
|
||||
|
||||
var rows = await payments
|
||||
.OrderByDescending(item => item.payment.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new AdminPaymentList(rows.Select(item => ToPaymentItem(item.payment, item.OrderNo)).ToArray());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
190
Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs
Normal file
190
Tiku.Infrastructure/Commerce/Orders/CommerceService.Orders.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed partial class CommerceService
|
||||
{
|
||||
public async Task<CommerceOrderItem> CreateOrderAsync(
|
||||
CommerceActor actor,
|
||||
CreateCommerceOrderCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command.Quantity is < 1 or > 99)
|
||||
{
|
||||
throw new CommerceException("Quantity must be between 1 and 99.", "invalid_quantity");
|
||||
}
|
||||
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var plan = await dbContext.SvipPlans
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.PlanId &&
|
||||
item.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("SVIP plan was not found.", "svip_plan_not_found");
|
||||
|
||||
if (plan.CouponOnly &&
|
||||
string.IsNullOrWhiteSpace(command.CouponCode) &&
|
||||
!command.CouponRedemptionId.HasValue)
|
||||
{
|
||||
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
||||
}
|
||||
|
||||
if (command.RegionId.HasValue)
|
||||
{
|
||||
var regionExists = await dbContext.Regions
|
||||
.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == command.RegionId.Value, cancellationToken);
|
||||
if (!regionExists)
|
||||
{
|
||||
throw new CommerceException("Region was not found.", "region_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
await using var transaction = dbContext.Database.IsRelational()
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var originalAmountCents = checked(plan.PriceCents * command.Quantity);
|
||||
var coupon = await ApplyCouponForOrderAsync(
|
||||
actor,
|
||||
command,
|
||||
plan,
|
||||
originalAmountCents,
|
||||
cancellationToken);
|
||||
if (plan.CouponOnly && coupon is null)
|
||||
{
|
||||
throw new CommerceException("This SVIP plan requires a coupon.", "coupon_required");
|
||||
}
|
||||
|
||||
var amountCents = Math.Max(0, originalAmountCents - (coupon?.DiscountCents ?? 0));
|
||||
var order = new Order
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
PlanId = plan.Id,
|
||||
RegionId = command.RegionId ?? plan.RegionId,
|
||||
OrderNo = GenerateOrderNo(),
|
||||
Status = OrderStatus.Pending,
|
||||
ProductType = "svip",
|
||||
ProductName = plan.Name,
|
||||
AmountCents = amountCents,
|
||||
PayMethod = NormalizeMethod(command.PayMethod),
|
||||
PayProvider = NormalizeProvider(command.PayProvider),
|
||||
Days = checked(plan.Days * command.Quantity),
|
||||
RawPayload = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
source = "student_checkout",
|
||||
command.Quantity,
|
||||
requestedCouponCode = command.CouponCode,
|
||||
requestedCouponRedemptionId = command.CouponRedemptionId,
|
||||
plan.PriceCents,
|
||||
plan.OriginalPriceCents,
|
||||
originalAmountCents,
|
||||
discountCents = coupon?.DiscountCents ?? 0,
|
||||
couponId = coupon?.Coupon.Id,
|
||||
couponCode = coupon?.Coupon.Code,
|
||||
couponRedemptionId = coupon?.Redemption.Id
|
||||
})
|
||||
};
|
||||
dbContext.Orders.Add(order);
|
||||
dbContext.OrderItems.Add(new OrderItem
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
ItemType = "svip_plan",
|
||||
ItemId = plan.Id,
|
||||
Name = plan.Name,
|
||||
Quantity = command.Quantity,
|
||||
UnitAmountCents = plan.PriceCents,
|
||||
TotalAmountCents = originalAmountCents,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
plan.Days,
|
||||
plan.RegionId,
|
||||
plan.VpProductId
|
||||
})
|
||||
});
|
||||
|
||||
if (coupon is not null)
|
||||
{
|
||||
coupon.Redemption.Status = CouponRedemptionStatus.Used;
|
||||
coupon.Redemption.OrderId = order.Id;
|
||||
coupon.Redemption.DiscountAppliedCents = coupon.DiscountCents;
|
||||
coupon.Redemption.UsedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (amountCents == 0)
|
||||
{
|
||||
var payment = new Payment
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = "manual",
|
||||
Method = "zero_amount",
|
||||
Status = PaymentStatus.Pending,
|
||||
AmountCents = 0
|
||||
};
|
||||
dbContext.Payments.Add(payment);
|
||||
await MarkPaidAsync(
|
||||
actor,
|
||||
order,
|
||||
payment,
|
||||
$"zero-{order.OrderNo}",
|
||||
order.RawPayload,
|
||||
"zero_amount_paid",
|
||||
$"zero-{order.OrderNo}",
|
||||
true,
|
||||
DateTimeOffset.UtcNow,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return ToOrderItem(order);
|
||||
}
|
||||
|
||||
public async Task<CommerceOrderList> GetOrdersAsync(
|
||||
CommerceActor actor,
|
||||
CommerceOrderQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var orders = dbContext.Orders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParseOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 20, 1, 100))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CommerceOrderList(items.Select(ToOrderItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CommerceOrderItem> GetOrderAsync(
|
||||
CommerceActor actor,
|
||||
string orderNo,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var order = await FindActorOrderAsync(actor, orderNo, cancellationToken);
|
||||
return ToOrderItem(order);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed partial class CommerceService
|
||||
{
|
||||
public async Task<PaymentNotificationProcessResult> ProcessPaymentNotificationAsync(
|
||||
Guid tenantId,
|
||||
string provider,
|
||||
IReadOnlyDictionary<string, string> headers,
|
||||
string rawBody,
|
||||
JsonElement body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedProvider = NormalizeProvider(provider);
|
||||
var notification = await paymentGateway.ParsePaymentNotificationAsync(
|
||||
normalizedProvider,
|
||||
new PaymentNotificationRequest(
|
||||
tenantId,
|
||||
normalizedProvider,
|
||||
headers,
|
||||
rawBody,
|
||||
body),
|
||||
cancellationToken);
|
||||
|
||||
if (!notification.SignatureValid)
|
||||
{
|
||||
throw new CommerceException("Payment notification signature is invalid.", "payment_signature_invalid");
|
||||
}
|
||||
|
||||
var alreadyProcessed = await dbContext.PaymentEvents.AnyAsync(
|
||||
item =>
|
||||
item.Provider == normalizedProvider &&
|
||||
item.EventId == notification.EventId &&
|
||||
item.ProcessedAt != null,
|
||||
cancellationToken);
|
||||
if (alreadyProcessed)
|
||||
{
|
||||
return new PaymentNotificationProcessResult(
|
||||
normalizedProvider,
|
||||
notification.EventId,
|
||||
notification.OrderNo,
|
||||
"processed",
|
||||
true);
|
||||
}
|
||||
|
||||
var order = await dbContext.Orders
|
||||
.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.OrderNo == notification.OrderNo,
|
||||
cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
|
||||
if (order.UserId is null)
|
||||
{
|
||||
throw new CommerceException("Order does not belong to a user.", "order_user_missing");
|
||||
}
|
||||
|
||||
if (order.AmountCents != notification.AmountCents)
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Provider = normalizedProvider,
|
||||
EventType = notification.EventType,
|
||||
EventId = notification.EventId,
|
||||
SignatureValid = true,
|
||||
Payload = notification.RawPayload,
|
||||
Error = "payment_amount_mismatch"
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new CommerceException("Payment amount does not match order amount.", "payment_amount_mismatch");
|
||||
}
|
||||
|
||||
var payment = await dbContext.Payments
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.OrderId == order.Id &&
|
||||
item.Provider == normalizedProvider)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (payment is null)
|
||||
{
|
||||
payment = new Payment
|
||||
{
|
||||
TenantId = tenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = normalizedProvider,
|
||||
Method = order.PayMethod,
|
||||
Status = PaymentStatus.Pending,
|
||||
AmountCents = order.AmountCents
|
||||
};
|
||||
dbContext.Payments.Add(payment);
|
||||
}
|
||||
|
||||
if (notification.Paid && order.Status == OrderStatus.Pending)
|
||||
{
|
||||
await MarkPaidAsync(
|
||||
new CommerceActor(tenantId, order.UserId.Value),
|
||||
order,
|
||||
payment,
|
||||
notification.ProviderTradeNo,
|
||||
notification.RawPayload,
|
||||
notification.EventType,
|
||||
notification.EventId,
|
||||
notification.SignatureValid,
|
||||
notification.PaidAt,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PaymentId = payment.Id,
|
||||
Provider = normalizedProvider,
|
||||
EventType = notification.EventType,
|
||||
EventId = notification.EventId,
|
||||
SignatureValid = notification.SignatureValid,
|
||||
Payload = notification.RawPayload,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new PaymentNotificationProcessResult(
|
||||
normalizedProvider,
|
||||
notification.EventId,
|
||||
notification.OrderNo,
|
||||
"processed",
|
||||
false);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<TenantPaymentProviderItem>> GetPaymentAccountsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var accounts = await providerConfigService.GetProvidersAsync(
|
||||
actor.TenantId,
|
||||
TenantExternalProviderCapability.Payment,
|
||||
string.IsNullOrWhiteSpace(query.Provider) ? null : NormalizeProvider(query.Provider),
|
||||
query.Limit,
|
||||
cancellationToken);
|
||||
|
||||
return accounts.Select(ToPaymentAccountItem).ToArray();
|
||||
}
|
||||
|
||||
public async Task<TenantPaymentProviderItem> UpsertPaymentAccountAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertPaymentAccountCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var account = await providerConfigService.UpsertProviderAsync(
|
||||
actor.TenantId,
|
||||
new UpsertTenantExternalProviderCommand(
|
||||
TenantExternalProviderCapability.Payment,
|
||||
command.Provider,
|
||||
command.Status,
|
||||
command.DisplayName,
|
||||
command.SecretRef,
|
||||
command.Priority,
|
||||
WithPaymentMode(command.ConfigPublic, command.Mode),
|
||||
JsonObjectOrDefault(default)),
|
||||
cancellationToken);
|
||||
|
||||
return ToPaymentAccountItem(account);
|
||||
}
|
||||
|
||||
public async Task<TenantSecretItem> UpsertTenantSecretAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertTenantSecretCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var secretRef = string.IsNullOrWhiteSpace(command.SecretRef)
|
||||
? $"tenant_secrets:{command.Purpose}:{NormalizeProvider(command.Provider)}:{command.SecretKey}"
|
||||
: command.SecretRef.Trim();
|
||||
var provider = NormalizeProvider(command.Provider);
|
||||
var secret = await dbContext.TenantSecrets
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.SecretRef == secretRef, cancellationToken);
|
||||
if (secret is null)
|
||||
{
|
||||
secret = new TenantSecret
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
SecretRef = secretRef
|
||||
};
|
||||
dbContext.TenantSecrets.Add(secret);
|
||||
}
|
||||
else
|
||||
{
|
||||
secret.RotatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
var protectedPayload = tenantSecretProtector.Protect(
|
||||
actor.TenantId,
|
||||
secretRef,
|
||||
JsonObjectOrDefault(command.SecretPayload));
|
||||
|
||||
secret.Purpose = command.Purpose.Trim();
|
||||
secret.Provider = provider;
|
||||
secret.SecretKey = command.SecretKey.Trim();
|
||||
secret.Status = command.Status;
|
||||
secret.EncryptionKeyId = protectedPayload.KeyId;
|
||||
secret.EncryptedPayload = protectedPayload.Ciphertext;
|
||||
secret.EncryptionNonce = protectedPayload.Nonce;
|
||||
secret.EncryptionTag = protectedPayload.Tag;
|
||||
secret.ExpiresAt = command.ExpiresAt;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToSecretItem(secret);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed partial class CommerceService
|
||||
{
|
||||
public async Task<CommercePaymentItem> CreatePaymentAsync(
|
||||
CommerceActor actor,
|
||||
CreateCommercePaymentCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var order = await FindActorOrderAsync(actor, command.OrderNo, cancellationToken);
|
||||
if (order.Status != OrderStatus.Pending)
|
||||
{
|
||||
throw new CommerceException("Only pending orders can create payments.", "order_status_invalid");
|
||||
}
|
||||
|
||||
var provider = NormalizeProvider(command.Provider);
|
||||
var method = NormalizeMethod(command.Method);
|
||||
var payment = await dbContext.Payments
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.OrderId == order.Id &&
|
||||
item.Provider == provider &&
|
||||
item.Status == PaymentStatus.Pending)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (payment is null)
|
||||
{
|
||||
payment = new Payment
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
Provider = provider,
|
||||
Method = method,
|
||||
Status = PaymentStatus.Pending,
|
||||
AmountCents = order.AmountCents
|
||||
};
|
||||
dbContext.Payments.Add(payment);
|
||||
}
|
||||
|
||||
var result = await paymentGateway.CreatePaymentAsync(
|
||||
provider,
|
||||
new CreatePaymentProviderRequest(
|
||||
actor.TenantId,
|
||||
order.OrderNo,
|
||||
order.ProductName ?? order.OrderNo,
|
||||
order.AmountCents,
|
||||
method,
|
||||
command.OpenId,
|
||||
command.ReturnUrl,
|
||||
command.QuitUrl,
|
||||
$"/api/student/commerce/payments/notify/{provider.Replace("_", "-", StringComparison.Ordinal)}?tenantId={actor.TenantId}",
|
||||
JsonSerializer.SerializeToElement(new { order.Id, actor.UserId })),
|
||||
cancellationToken);
|
||||
|
||||
payment.Method = result.Method;
|
||||
payment.RawPayload = result.RawPayload;
|
||||
if (!string.IsNullOrWhiteSpace(result.ProviderTradeNo))
|
||||
{
|
||||
payment.ProviderTradeNo = result.ProviderTradeNo;
|
||||
}
|
||||
|
||||
if (IsPaid(result.Status))
|
||||
{
|
||||
await MarkPaidAsync(
|
||||
actor,
|
||||
order,
|
||||
payment,
|
||||
result.ProviderTradeNo,
|
||||
result.RawPayload,
|
||||
"payment_paid",
|
||||
result.ProviderTradeNo,
|
||||
true,
|
||||
null,
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.PaymentEvents.Add(new PaymentEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
PaymentId = payment.Id,
|
||||
Provider = provider,
|
||||
EventType = "payment_created",
|
||||
Payload = result.RawPayload
|
||||
});
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToPaymentItem(payment, order.OrderNo, result.ClientPayload);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
public async Task<TenantPointTaskList> GetPointTasksAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var tasks = dbContext.PointActivityTasks.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
tasks = tasks.Where(item => item.Status == ParsePointTaskStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await tasks
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointTaskList(items);
|
||||
}
|
||||
|
||||
public async Task<PointActivityTask> UpsertPointTaskAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertPointTaskCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.Points <= 0 || command.MaxClaimsPerUser <= 0)
|
||||
{
|
||||
throw new CommerceException("Point task points and claim limit must be positive.", "invalid_point_task");
|
||||
}
|
||||
|
||||
var task = command.Id.HasValue
|
||||
? await dbContext.PointActivityTasks.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: await dbContext.PointActivityTasks.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.TaskKey == command.TaskKey.Trim(),
|
||||
cancellationToken);
|
||||
if (task is null)
|
||||
{
|
||||
task = new PointActivityTask { TenantId = actor.TenantId };
|
||||
dbContext.PointActivityTasks.Add(task);
|
||||
}
|
||||
|
||||
task.TaskKey = command.TaskKey.Trim();
|
||||
task.Title = command.Title.Trim();
|
||||
task.Description = command.Description?.Trim();
|
||||
task.TaskType = command.TaskType;
|
||||
task.Status = command.Status;
|
||||
task.Points = command.Points;
|
||||
task.MaxClaimsPerUser = command.MaxClaimsPerUser;
|
||||
task.StartsAt = command.StartsAt;
|
||||
task.EndsAt = command.EndsAt;
|
||||
task.SortOrder = command.SortOrder;
|
||||
task.Rules = JsonObjectOrDefault(command.Rules);
|
||||
task.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TenantPointClaimList> GetPointClaimsAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var claims = dbContext.PointActivityClaims.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
claims = claims.Where(item => item.UserId == query.UserId.Value);
|
||||
}
|
||||
|
||||
var items = await claims
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointClaimList(items);
|
||||
}
|
||||
|
||||
public async Task<TenantPointExchangeItemList> GetPointExchangeItemsAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var items = dbContext.PointExchangeItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
items = items.Where(item => item.Status == ParsePointExchangeItemStatus(query.Status));
|
||||
}
|
||||
|
||||
if (query.RegionId.HasValue)
|
||||
{
|
||||
items = items.Where(item => item.RegionId == null || item.RegionId == query.RegionId.Value);
|
||||
}
|
||||
|
||||
var result = await items
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointExchangeItemList(result);
|
||||
}
|
||||
|
||||
public async Task<PointExchangeItem> UpsertPointExchangeItemAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpsertPointExchangeItemCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.PointsCost <= 0)
|
||||
{
|
||||
throw new CommerceException("Point exchange item cost must be positive.", "invalid_point_exchange_item");
|
||||
}
|
||||
|
||||
var item = command.Id.HasValue
|
||||
? await dbContext.PointExchangeItems.SingleOrDefaultAsync(
|
||||
entry => entry.TenantId == actor.TenantId && entry.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: await dbContext.PointExchangeItems.SingleOrDefaultAsync(
|
||||
entry => entry.TenantId == actor.TenantId && entry.ItemKey == command.ItemKey.Trim(),
|
||||
cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
item = new PointExchangeItem { TenantId = actor.TenantId };
|
||||
dbContext.PointExchangeItems.Add(item);
|
||||
}
|
||||
|
||||
item.RegionId = command.RegionId;
|
||||
item.ItemKey = command.ItemKey.Trim();
|
||||
item.Name = command.Name.Trim();
|
||||
item.Description = command.Description?.Trim();
|
||||
item.ItemType = command.ItemType;
|
||||
item.Status = command.Status;
|
||||
item.PointsCost = command.PointsCost;
|
||||
item.Stock = command.Stock;
|
||||
item.Days = command.Days;
|
||||
item.SortOrder = command.SortOrder;
|
||||
item.StartsAt = command.StartsAt;
|
||||
item.EndsAt = command.EndsAt;
|
||||
item.FulfillmentPayload = JsonObjectOrDefault(command.FulfillmentPayload);
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<TenantPointExchangeOrderList> GetPointExchangeOrdersAsync(
|
||||
CommerceAdminActor actor,
|
||||
TenantPointQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var orders = dbContext.PointExchangeOrders.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
orders = orders.Where(item => item.UserId == query.UserId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
orders = orders.Where(item => item.Status == ParsePointExchangeOrderStatus(query.Status));
|
||||
}
|
||||
|
||||
var result = await orders
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantPointExchangeOrderList(result);
|
||||
}
|
||||
|
||||
public async Task<PointExchangeOrder> UpdatePointExchangeOrderStatusAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdatePointExchangeOrderStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var order = await dbContext.PointExchangeOrders
|
||||
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == command.OrderId, cancellationToken)
|
||||
?? throw new CommerceException("Point exchange order was not found.", "point_exchange_order_not_found");
|
||||
order.Status = command.Status;
|
||||
if (command.Status == PointExchangeOrderStatus.Completed)
|
||||
{
|
||||
order.CompletedAt ??= DateTimeOffset.UtcNow;
|
||||
order.CancelledAt = null;
|
||||
}
|
||||
else if (command.Status == PointExchangeOrderStatus.Cancelled)
|
||||
{
|
||||
order.CancelledAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return order;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
public async Task<TenantReconciliationBatchList> GetReconciliationBatchesAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var batches = dbContext.CommerceReconciliationBatches.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
var provider = NormalizeProvider(query.Provider);
|
||||
batches = batches.Where(item => item.Provider == provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
batches = batches.Where(item => item.Status == ParseReconciliationBatchStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await batches.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantReconciliationBatchList(items);
|
||||
}
|
||||
|
||||
public async Task<CommerceReconciliationBatch> CreateReconciliationBatchAsync(
|
||||
CommerceAdminActor actor,
|
||||
CreateReconciliationBatchCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var batch = new CommerceReconciliationBatch
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
Provider = NormalizeProvider(command.Provider),
|
||||
BillDate = command.BillDate,
|
||||
BillType = command.BillType,
|
||||
Source = command.Source,
|
||||
SourceName = command.SourceName?.Trim(),
|
||||
SourceHash = command.SourceHash.Trim(),
|
||||
Status = ReconciliationBatchStatus.Pending,
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.CommerceReconciliationBatches.Add(batch);
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_batch.created", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return batch;
|
||||
}
|
||||
|
||||
public async Task<TenantReconciliationIssueList> GetReconciliationIssuesAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var issues = dbContext.CommerceReconciliationIssues.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(query.Provider))
|
||||
{
|
||||
var provider = NormalizeProvider(query.Provider);
|
||||
issues = issues.Where(item => item.Provider == provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
issues = issues.Where(item => item.Status == ParseReconciliationIssueStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await issues.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantReconciliationIssueList(items);
|
||||
}
|
||||
|
||||
public async Task<CommerceReconciliationIssue> UpdateReconciliationIssueAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdateReconciliationIssueCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var issue = await dbContext.CommerceReconciliationIssues.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.IssueId,
|
||||
cancellationToken) ?? throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found");
|
||||
var fromStatus = issue.Status;
|
||||
issue.Status = command.Status;
|
||||
issue.ResolutionType = command.ResolutionType;
|
||||
issue.ResolutionNote = command.Note?.Trim();
|
||||
issue.AssignedTo = command.AssignedTo ?? issue.AssignedTo;
|
||||
if (command.Status is ReconciliationIssueStatus.Resolved or ReconciliationIssueStatus.Ignored)
|
||||
{
|
||||
issue.ResolvedBy = actor.UserId;
|
||||
issue.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
dbContext.CommerceReconciliationIssueEvents.Add(new CommerceReconciliationIssueEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
IssueId = issue.Id,
|
||||
FromStatus = fromStatus,
|
||||
ToStatus = command.Status,
|
||||
EventType = "status_changed",
|
||||
ActorUserId = actor.UserId,
|
||||
Note = command.Note,
|
||||
Details = JsonSerializer.SerializeToElement(new { command.ResolutionType, command.AssignedTo })
|
||||
});
|
||||
await AddAuditAsync(actor, "commerce.reconciliation_issue.status_changed", "commerce_reconciliation_issues", issue.Id, new { issue.IssueNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return issue;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Globalization;
|
||||
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;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed partial class CommerceAdminService
|
||||
{
|
||||
public async Task<TenantRefundList> GetRefundsAsync(
|
||||
CommerceAdminActor actor,
|
||||
CommerceAdminQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var refunds = dbContext.CommerceRefundRequests.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
|
||||
item => dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId &&
|
||||
order.Id == item.OrderId &&
|
||||
order.RegionId.HasValue &&
|
||||
regionIds.Contains(order.RegionId.Value)));
|
||||
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||
{
|
||||
refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status));
|
||||
}
|
||||
|
||||
var items = await refunds
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantRefundList(items);
|
||||
}
|
||||
|
||||
public async Task<CommerceRefundRequest> CreateRefundRequestAsync(
|
||||
CommerceAdminActor actor,
|
||||
CreateRefundRequestCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var order = await dbContext.Orders
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.UserId == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new CommerceException("Order was not found.", "order_not_found");
|
||||
if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded))
|
||||
{
|
||||
throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable");
|
||||
}
|
||||
|
||||
if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents)
|
||||
{
|
||||
throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount");
|
||||
}
|
||||
|
||||
if (command.PaymentId.HasValue)
|
||||
{
|
||||
var paymentExists = await dbContext.Payments.AnyAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id,
|
||||
cancellationToken);
|
||||
if (!paymentExists)
|
||||
{
|
||||
throw new CommerceException("Payment was not found.", "payment_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
var refund = new CommerceRefundRequest
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
OrderId = order.Id,
|
||||
PaymentId = command.PaymentId,
|
||||
RequestedBy = actor.UserId,
|
||||
RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}",
|
||||
Provider = order.PayProvider,
|
||||
Status = CommerceRefundStatus.Requested,
|
||||
AmountCents = command.AmountCents,
|
||||
Reason = command.Reason?.Trim(),
|
||||
EntitlementAction = command.EntitlementAction,
|
||||
Metadata = JsonObjectOrDefault(command.Metadata)
|
||||
};
|
||||
dbContext.CommerceRefundRequests.Add(refund);
|
||||
AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason });
|
||||
await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
public async Task<CommerceRefundRequest> UpdateRefundStatusAsync(
|
||||
CommerceAdminActor actor,
|
||||
UpdateRefundStatusCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var refund = await dbContext.CommerceRefundRequests
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
|
||||
item => dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId &&
|
||||
order.Id == item.OrderId &&
|
||||
order.RegionId.HasValue &&
|
||||
regionIds.Contains(order.RegionId.Value)))
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
?? throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
var fromStatus = refund.Status;
|
||||
if (!IsAllowedRefundTransition(fromStatus, command.Status))
|
||||
{
|
||||
throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition");
|
||||
}
|
||||
|
||||
refund.Status = command.Status;
|
||||
refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo)
|
||||
? refund.ProviderRefundNo
|
||||
: command.ProviderRefundNo.Trim();
|
||||
switch (command.Status)
|
||||
{
|
||||
case CommerceRefundStatus.Approved:
|
||||
refund.ReviewedBy = actor.UserId;
|
||||
refund.ReviewedAt = DateTimeOffset.UtcNow;
|
||||
break;
|
||||
case CommerceRefundStatus.Processing:
|
||||
refund.ProcessedBy = actor.UserId;
|
||||
refund.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
break;
|
||||
case CommerceRefundStatus.Succeeded:
|
||||
refund.SucceededAt = DateTimeOffset.UtcNow;
|
||||
await ApplyRefundToOrderAsync(refund, cancellationToken);
|
||||
break;
|
||||
case CommerceRefundStatus.Failed:
|
||||
refund.FailedAt = DateTimeOffset.UtcNow;
|
||||
refund.FailureReason = command.Reason;
|
||||
break;
|
||||
case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected:
|
||||
refund.CancelledAt = DateTimeOffset.UtcNow;
|
||||
break;
|
||||
}
|
||||
|
||||
AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo });
|
||||
await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
public async Task<TenantRefundEventList> GetRefundEventsAsync(
|
||||
CommerceAdminActor actor,
|
||||
Guid refundRequestId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var refundExists = await dbContext.CommerceRefundRequests
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.RequestedBy == actor.UserId || dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId),
|
||||
item => dbContext.Orders.Any(order =>
|
||||
order.TenantId == actor.TenantId &&
|
||||
order.Id == item.OrderId &&
|
||||
order.RegionId.HasValue &&
|
||||
regionIds.Contains(order.RegionId.Value)))
|
||||
.AnyAsync(cancellationToken);
|
||||
if (!refundExists)
|
||||
{
|
||||
throw new CommerceException("Refund request was not found.", "refund_not_found");
|
||||
}
|
||||
|
||||
var items = await dbContext.CommerceRefundEvents.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new TenantRefundEventList(items);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class ContentManagementService
|
||||
{
|
||||
public async Task<CatalogList<QuestionCollectionManagementItem>> GetCollectionsAsync(
|
||||
ContentManagementActor actor,
|
||||
ContentManagementFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var query = dbContext.QuestionCollections
|
||||
.AsNoTracking()
|
||||
.Where(collection => collection.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
collection => collection.CreatedBy == actor.UserId,
|
||||
collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(collection => collection.Status == ContentStatus.Active);
|
||||
}
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
|
||||
if (filter.NodeId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
|
||||
}
|
||||
|
||||
if (TryParse(filter.CollectionType, out QuestionCollectionType collectionType))
|
||||
{
|
||||
query = query.Where(collection => collection.CollectionType == collectionType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(collection => collection.Name.Contains(keyword));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(collection => collection.SortOrder)
|
||||
.ThenBy(collection => collection.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(collection => ToCollectionItem(collection))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionCollectionManagementItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<QuestionCollectionManagementItem>> UpsertCollectionAsync(
|
||||
ContentManagementActor actor,
|
||||
UpsertQuestionCollectionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
||||
await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken);
|
||||
await AssertNodeAsync(actor, scope, command.NodeId, cancellationToken);
|
||||
await AssertReferenceAsync<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionBank>(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
|
||||
|
||||
var collection = await ResolveEntityByIdOrLegacyAsync(
|
||||
dbContext.QuestionCollections,
|
||||
actor.TenantId,
|
||||
command.Id,
|
||||
command.LegacyId,
|
||||
cancellationToken);
|
||||
|
||||
var isNew = collection is null;
|
||||
if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value))
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
collection ??= new QuestionCollection
|
||||
{
|
||||
Id = command.Id ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
|
||||
collection.RegionId = command.RegionId;
|
||||
collection.EntryId = command.EntryId;
|
||||
collection.NodeId = command.NodeId;
|
||||
collection.SubjectId = command.SubjectId;
|
||||
collection.CategoryId = command.CategoryId;
|
||||
collection.QuestionBankId = command.QuestionBankId;
|
||||
collection.LegacyId = Normalize(command.LegacyId);
|
||||
collection.Name = command.Name.Trim();
|
||||
collection.CollectionType = Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid");
|
||||
collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, "collection_source_type_invalid");
|
||||
collection.Filters = JsonObjectOrDefault(command.Filters);
|
||||
collection.TotalScore = command.TotalScore;
|
||||
collection.DurationMinutes = command.DurationMinutes;
|
||||
collection.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid");
|
||||
collection.SortOrder = command.Order ?? 0;
|
||||
collection.AccessRules = JsonObjectOrDefault(command.AccessRules);
|
||||
collection.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
collection.UpdatedBy = actor.UserId;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.QuestionCollections.Add(collection);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<QuestionCollectionManagementItem>(ToCollectionItem(collection));
|
||||
}
|
||||
|
||||
public async Task<CollectionItemsReplaceResult> ReplaceCollectionItemsAsync(
|
||||
ContentManagementActor actor,
|
||||
ReplaceCollectionItemsCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var collection = await dbContext.QuestionCollections
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
item => item.CreatedBy == actor.UserId,
|
||||
item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (collection is null)
|
||||
{
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>();
|
||||
foreach (var question in command.Questions)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
question.Locator,
|
||||
cancellationToken);
|
||||
resolvedQuestions.Add((question, reference));
|
||||
}
|
||||
|
||||
var oldItems = await dbContext.QuestionCollectionItems
|
||||
.Where(item => item.TenantId == actor.TenantId && item.CollectionId == command.CollectionId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
dbContext.QuestionCollectionItems.RemoveRange(oldItems);
|
||||
|
||||
var items = resolvedQuestions
|
||||
.Select((resolved, index) => new QuestionCollectionItem
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CollectionId = command.CollectionId,
|
||||
QuestionReferenceId = resolved.Reference.Id,
|
||||
QuestionOwnerTenantId = resolved.Reference.QuestionOwnerTenantId,
|
||||
QuestionId = resolved.Reference.QuestionId,
|
||||
SectionKey = Normalize(resolved.Command.SectionKey),
|
||||
SortOrder = resolved.Command.Order ?? index,
|
||||
Score = resolved.Command.Score,
|
||||
Required = resolved.Command.Required ?? true,
|
||||
Metadata = JsonObjectOrDefault(resolved.Command.Metadata)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
dbContext.QuestionCollectionItems.AddRange(items);
|
||||
collection.QuestionCount = items.Length;
|
||||
collection.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new CollectionItemsReplaceResult(
|
||||
command.CollectionId,
|
||||
collection.QuestionCount,
|
||||
items.Select(ToCollectionItemItem).ToArray());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -378,7 +378,3 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RequiredFieldException(string message) : Exception(message);
|
||||
|
||||
public sealed class ContentNavigationNotFoundException(string message) : Exception(message);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class DirectContentService
|
||||
{
|
||||
public async Task<CatalogList<School>> GetSchoolsAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var query = dbContext.Schools.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword));
|
||||
}
|
||||
|
||||
return new CatalogList<School>(await query
|
||||
.OrderBy(item => item.Name)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<School>> UpsertSchoolAsync(
|
||||
DirectContentActor actor,
|
||||
SchoolCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Schools, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "school_not_found");
|
||||
item ??= new School { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Name = command.Name.Trim();
|
||||
item.ProfessionalExamDate = Normalize(command.ProfessionalExamDate);
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Schools.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<School>(item);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<Major>> GetMajorsAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var query = dbContext.Majors.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<Major>(await query
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<Major>> UpsertMajorAsync(
|
||||
DirectContentActor actor,
|
||||
MajorCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Majors, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "major_not_found");
|
||||
item ??= new Major { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.SchoolId = command.SchoolId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Name = command.Name.Trim();
|
||||
item.Description = Normalize(command.Description);
|
||||
item.StudyTips = Normalize(command.StudyTips);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Majors.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<Major>(item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class ContentManagementService
|
||||
{
|
||||
public async Task<CatalogList<ContentEntryManagementItem>> GetEntriesAsync(
|
||||
ContentManagementActor actor,
|
||||
ContentManagementFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var query = dbContext.ContentEntries
|
||||
.AsNoTracking()
|
||||
.Where(entry => entry.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
entry => entry.CreatedBy == actor.UserId,
|
||||
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive)
|
||||
{
|
||||
query = query.Where(entry => entry.IsActive);
|
||||
}
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(entry => entry.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (TryParse(filter.EntryType, out ContentEntryType entryType))
|
||||
{
|
||||
query = query.Where(entry => entry.EntryType == entryType);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(entry =>
|
||||
entry.Name.Contains(keyword) ||
|
||||
entry.EntryKey.Contains(keyword) ||
|
||||
(entry.Description != null && entry.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderBy(entry => entry.SortOrder)
|
||||
.ThenBy(entry => entry.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(entry => ToEntryItem(entry))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ContentEntryManagementItem>(items);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentEntryManagementItem>> UpsertEntryAsync(
|
||||
ContentManagementActor actor,
|
||||
UpsertContentEntryCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken);
|
||||
|
||||
var entryKey = Normalize(command.EntryKey) ??
|
||||
Normalize(command.Id?.ToString("N")) ??
|
||||
Guid.NewGuid().ToString("N");
|
||||
var entry = await ResolveEntityAsync(
|
||||
dbContext.ContentEntries,
|
||||
actor.TenantId,
|
||||
command.Id,
|
||||
item => item.EntryKey == entryKey,
|
||||
cancellationToken);
|
||||
|
||||
var isNew = entry is null;
|
||||
if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value))
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
|
||||
if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
|
||||
if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId))
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
|
||||
entry ??= new ContentEntry
|
||||
{
|
||||
Id = command.Id ?? Guid.NewGuid(),
|
||||
TenantId = actor.TenantId,
|
||||
EntryKey = entryKey,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
|
||||
entry.RegionId = command.RegionId;
|
||||
entry.LegacyId = Normalize(command.LegacyId);
|
||||
entry.Name = command.Name.Trim();
|
||||
entry.EntryType = Parse(command.EntryType, ContentEntryType.QuestionPractice, "entry_type_invalid");
|
||||
entry.Icon = Normalize(command.Icon);
|
||||
entry.Route = Normalize(command.Route);
|
||||
entry.Description = Normalize(command.Description);
|
||||
entry.Visibility = Parse(command.Visibility, ContentVisibility.Public, "visibility_invalid");
|
||||
entry.AccessRules = JsonObjectOrDefault(command.AccessRules);
|
||||
entry.LayoutConfig = JsonObjectOrDefault(command.LayoutConfig);
|
||||
entry.SortOrder = command.Order ?? 0;
|
||||
entry.IsActive = command.IsActive ?? true;
|
||||
entry.UpdatedBy = actor.UserId;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ContentEntries.Add(entry);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ContentEntryManagementItem>(ToEntryItem(entry));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class ContentManagementService
|
||||
{
|
||||
private async Task<(string Path, int Depth)> BuildNodePathAsync(
|
||||
Guid tenantId,
|
||||
Guid entryId,
|
||||
Guid nodeId,
|
||||
Guid? parentId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var label = $"n_{nodeId:N}";
|
||||
if (!parentId.HasValue)
|
||||
{
|
||||
return (label, 0);
|
||||
}
|
||||
|
||||
var parent = await dbContext.ContentNodes
|
||||
.AsNoTracking()
|
||||
.Where(node => node.TenantId == tenantId && node.EntryId == entryId && node.Id == parentId.Value)
|
||||
.Select(node => new { node.Path, node.Depth })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (parent is null)
|
||||
{
|
||||
throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found");
|
||||
}
|
||||
|
||||
return ($"{parent.Path}.{label}", parent.Depth + 1);
|
||||
}
|
||||
|
||||
private async Task AssertRegionAsync(Guid tenantId, Guid? regionId, CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<Region>(tenantId, regionId, "region_not_found", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<ContentEntry>(tenantId, entryId, "entry_not_found", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AssertEntryAsync(
|
||||
ContentManagementActor actor,
|
||||
CurrentDataScope scope,
|
||||
Guid? entryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!entryId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var exists = await dbContext.ContentEntries
|
||||
.Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
entry => entry.CreatedBy == actor.UserId,
|
||||
entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value))
|
||||
.AnyAsync(cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Content entry was not found.", "entry_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AssertNodeAsync(
|
||||
ContentManagementActor actor,
|
||||
CurrentDataScope scope,
|
||||
Guid? nodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!nodeId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var exists = await dbContext.ContentNodes
|
||||
.Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value)
|
||||
.ApplyDataScope(
|
||||
scope,
|
||||
node => node.CreatedBy == actor.UserId,
|
||||
node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value))
|
||||
.AnyAsync(cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Content node was not found.", "node_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CurrentDataScope> RequireDataScopeAsync(
|
||||
ContentManagementActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId)
|
||||
{
|
||||
throw new ContentManagementException("Content resource was not found.", "content_not_found");
|
||||
}
|
||||
|
||||
return access.DataScope;
|
||||
}
|
||||
|
||||
private async Task AssertReferenceAsync<TEntity>(
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await dbContext.Set<TEntity>()
|
||||
.AnyAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Referenced entity was not found in this tenant.", code);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TEntity?> ResolveEntityAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
System.Linq.Expressions.Expression<Func<TEntity, bool>> alternatePredicate,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
var byId = await set.SingleOrDefaultAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
cancellationToken);
|
||||
if (byId is not null)
|
||||
{
|
||||
return byId;
|
||||
}
|
||||
}
|
||||
|
||||
return await set
|
||||
.Where(entity => EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId)
|
||||
.SingleOrDefaultAsync(alternatePredicate, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<TEntity?> ResolveEntityByIdOrLegacyAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string? legacyId,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
var byId = await set.SingleOrDefaultAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
|
||||
cancellationToken);
|
||||
if (byId is not null)
|
||||
{
|
||||
return byId;
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedLegacyId = Normalize(legacyId);
|
||||
if (normalizedLegacyId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await set.SingleOrDefaultAsync(entity =>
|
||||
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
|
||||
EF.Property<string?>(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static ContentEntryManagementItem ToEntryItem(ContentEntry entry)
|
||||
{
|
||||
return new ContentEntryManagementItem(
|
||||
entry.Id,
|
||||
entry.RegionId,
|
||||
entry.LegacyId,
|
||||
entry.EntryKey,
|
||||
entry.Name,
|
||||
entry.EntryType,
|
||||
entry.Icon,
|
||||
entry.Route,
|
||||
entry.Description,
|
||||
entry.Visibility,
|
||||
entry.AccessRules,
|
||||
entry.LayoutConfig,
|
||||
entry.SortOrder,
|
||||
entry.IsActive,
|
||||
entry.CreatedAt,
|
||||
entry.UpdatedAt);
|
||||
}
|
||||
|
||||
private static ContentNodeManagementItem ToNodeItem(ContentNode node)
|
||||
{
|
||||
return new ContentNodeManagementItem(
|
||||
node.Id,
|
||||
node.EntryId,
|
||||
node.RegionId,
|
||||
node.ParentId,
|
||||
node.LegacyId,
|
||||
node.NodeKey,
|
||||
node.Name,
|
||||
node.NodeType,
|
||||
node.MarkerType,
|
||||
node.MarkerConfig,
|
||||
node.Path,
|
||||
node.Depth,
|
||||
node.SortOrder,
|
||||
node.IsActive,
|
||||
node.IsSelectable,
|
||||
node.IsLeaf,
|
||||
node.AccessRules,
|
||||
node.Metadata,
|
||||
node.CreatedAt,
|
||||
node.UpdatedAt);
|
||||
}
|
||||
|
||||
private static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection)
|
||||
{
|
||||
return new QuestionCollectionManagementItem(
|
||||
collection.Id,
|
||||
collection.RegionId,
|
||||
collection.EntryId,
|
||||
collection.NodeId,
|
||||
collection.SubjectId,
|
||||
collection.CategoryId,
|
||||
collection.QuestionBankId,
|
||||
collection.LegacyId,
|
||||
collection.Name,
|
||||
collection.CollectionType,
|
||||
collection.SourceType,
|
||||
collection.Filters,
|
||||
collection.QuestionCount,
|
||||
collection.TotalScore,
|
||||
collection.DurationMinutes,
|
||||
collection.Status,
|
||||
collection.SortOrder,
|
||||
collection.AccessRules,
|
||||
collection.Metadata,
|
||||
collection.CreatedAt,
|
||||
collection.UpdatedAt);
|
||||
}
|
||||
|
||||
private static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item)
|
||||
{
|
||||
return new QuestionCollectionItemManagementItem(
|
||||
item.Id,
|
||||
item.CollectionId,
|
||||
item.QuestionId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.SectionKey,
|
||||
item.SortOrder,
|
||||
item.Score,
|
||||
item.Required,
|
||||
item.Metadata);
|
||||
}
|
||||
|
||||
private static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint)
|
||||
{
|
||||
return new PracticeBlueprintManagementItem(
|
||||
blueprint.Id,
|
||||
blueprint.RegionId,
|
||||
blueprint.EntryId,
|
||||
blueprint.NodeId,
|
||||
blueprint.CollectionId,
|
||||
blueprint.LegacyId,
|
||||
blueprint.Name,
|
||||
blueprint.Mode,
|
||||
blueprint.AssemblyType,
|
||||
blueprint.QuestionLimit,
|
||||
blueprint.DurationMinutes,
|
||||
blueprint.TotalScore,
|
||||
blueprint.PassScore,
|
||||
blueprint.Sections,
|
||||
blueprint.Rules,
|
||||
blueprint.AccessRules,
|
||||
blueprint.Status,
|
||||
blueprint.SortOrder,
|
||||
blueprint.CreatedAt,
|
||||
blueprint.UpdatedAt);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
return limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
|
||||
? JsonDefaults.Object()
|
||||
: value;
|
||||
}
|
||||
|
||||
private static JsonElement JsonArrayOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null
|
||||
? JsonDefaults.Array()
|
||||
: value;
|
||||
}
|
||||
|
||||
private static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new ContentManagementException("Invalid enum value.", code);
|
||||
}
|
||||
|
||||
private static TEnum? ParseNullable<TEnum>(string? value, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new ContentManagementException("Invalid enum value.", code);
|
||||
}
|
||||
|
||||
private static bool TryParse<TEnum>(string? value, out TEnum parsed)
|
||||
where TEnum : struct
|
||||
{
|
||||
return Enum.TryParse(value, ignoreCase: true, out parsed);
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string value)
|
||||
{
|
||||
return value.Contains(',') || value.Contains('"') || value.Contains('\n')
|
||||
? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\""
|
||||
: value;
|
||||
}
|
||||
|
||||
private static ImportSpec ResolveImportSpec(string importType)
|
||||
{
|
||||
var normalized = importType.Trim().ToLowerInvariant();
|
||||
return Specs.TryGetValue(normalized, out var spec)
|
||||
? spec
|
||||
: throw new ContentManagementException("Import type is not supported.", "import_type_invalid");
|
||||
}
|
||||
|
||||
private sealed record ImportSpec(
|
||||
string ImportType,
|
||||
string Title,
|
||||
string Description,
|
||||
IReadOnlyCollection<ImportFieldSpec> Fields,
|
||||
string[][] CsvRows,
|
||||
object JsonExample);
|
||||
|
||||
private static ImportFieldSpec Field(
|
||||
string field,
|
||||
string label,
|
||||
bool required,
|
||||
string[] aliases,
|
||||
string description,
|
||||
object example)
|
||||
{
|
||||
return new ImportFieldSpec(
|
||||
field,
|
||||
label,
|
||||
required,
|
||||
aliases,
|
||||
description,
|
||||
JsonSerializer.SerializeToElement(example));
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, ImportSpec> Specs =
|
||||
new Dictionary<string, ImportSpec>(StringComparer.Ordinal)
|
||||
{
|
||||
["questions"] = new(
|
||||
"questions",
|
||||
"题目导入模板",
|
||||
"用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。",
|
||||
[
|
||||
Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", "tj-english-2026-001"),
|
||||
Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", "choice"),
|
||||
Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", "多租户 SaaS 最重要的安全边界是什么?"),
|
||||
Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }),
|
||||
Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。", new[] { 1 }),
|
||||
Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"),
|
||||
Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"),
|
||||
Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2),
|
||||
Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" })
|
||||
],
|
||||
[
|
||||
["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"],
|
||||
["tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", "2", "安全|多租户"]
|
||||
],
|
||||
new
|
||||
{
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
legacyId = "tj-english-2026-001",
|
||||
type = "choice",
|
||||
content = "多租户 SaaS 最重要的安全边界是什么?",
|
||||
options = new[] { "前端隐藏", "后端权限" },
|
||||
correctOptionIndices = new[] { 1 },
|
||||
explanation = "最终权限以后端强制为准。",
|
||||
difficulty = 2,
|
||||
tags = new[] { "安全", "多租户" }
|
||||
}
|
||||
}
|
||||
}),
|
||||
["vocabulary"] = new(
|
||||
"vocabulary",
|
||||
"单词导入模板",
|
||||
"用于导入词汇单元和单词,后端会按单元归组并幂等写入。",
|
||||
[
|
||||
Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"),
|
||||
Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"),
|
||||
Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"),
|
||||
Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"),
|
||||
Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.")
|
||||
],
|
||||
[
|
||||
["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"],
|
||||
["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"]
|
||||
],
|
||||
new { units = new[] { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } } }),
|
||||
["handbook"] = new(
|
||||
"handbook",
|
||||
"知识手册导入模板",
|
||||
"用于导入手册科目、章节、小节和知识点。",
|
||||
[
|
||||
Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"),
|
||||
Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"),
|
||||
Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"),
|
||||
Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。")
|
||||
],
|
||||
[
|
||||
["subjectName", "chapterName", "title", "content", "tags"],
|
||||
["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"]
|
||||
],
|
||||
new { subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } }),
|
||||
["scoreline"] = new(
|
||||
"scoreline",
|
||||
"分数线导入模板",
|
||||
"用于导入动态字段、院校、专业和年份分数线记录。",
|
||||
[
|
||||
Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"),
|
||||
Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"),
|
||||
Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"),
|
||||
Field("year", "年份", false, ["年份"], "record 常用。", 2026),
|
||||
Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 })
|
||||
],
|
||||
[
|
||||
["kind", "schoolName", "majorName", "year", "minScore"],
|
||||
["record", "天津职业技术师范大学", "软件工程", "2026", "188"]
|
||||
],
|
||||
new { records = new[] { new { schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, fieldValues = new { minScore = 188 } } } }),
|
||||
["videos"] = new(
|
||||
"videos",
|
||||
"视频解析导入模板",
|
||||
"用于导入视频解析元数据并绑定到题目。",
|
||||
[
|
||||
Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"),
|
||||
Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", "https://cdn.example.test/video.mp4"),
|
||||
Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", "00000000-0000-0000-0000-000000000000"),
|
||||
Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", "tj-english-2026-001")
|
||||
],
|
||||
[
|
||||
["title", "videoUrl", "legacyQuestionId", "videoType"],
|
||||
["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"]
|
||||
],
|
||||
new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } })
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class DirectContentService
|
||||
{
|
||||
private static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version)
|
||||
{
|
||||
return new QuestionManagementItem(
|
||||
question.Id,
|
||||
version?.Id,
|
||||
question.QuestionBankId,
|
||||
question.SubjectId,
|
||||
question.CategoryId,
|
||||
question.NodeId,
|
||||
question.EntryId,
|
||||
question.ContentNodeId,
|
||||
question.PrimaryCollectionId,
|
||||
question.LegacyId,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version?.Content,
|
||||
version?.Options ?? JsonDefaults.Array(),
|
||||
version?.CorrectOptionIndex,
|
||||
version?.CorrectOptionIndices ?? JsonDefaults.Array(),
|
||||
version?.AnswerText,
|
||||
version?.Explanation,
|
||||
version?.SubQuestions ?? JsonDefaults.Array(),
|
||||
version?.CodeLang,
|
||||
version?.CodeTemplate,
|
||||
question.MediaUrl,
|
||||
question.HasVideoExplanation,
|
||||
question.Status);
|
||||
}
|
||||
|
||||
private static VideoManagementItem ToVideoItem(VideoExplanation item)
|
||||
{
|
||||
return new VideoManagementItem(
|
||||
item.Id,
|
||||
item.SubjectId,
|
||||
item.LegacyId,
|
||||
item.Title,
|
||||
item.Description,
|
||||
item.VideoUrl,
|
||||
item.ThumbnailUrl,
|
||||
item.DurationSeconds,
|
||||
item.KnowledgeTags,
|
||||
item.IsGeneral,
|
||||
item.Difficulty,
|
||||
item.SortOrder,
|
||||
item.IsActive,
|
||||
item.Metadata);
|
||||
}
|
||||
|
||||
private static QuestionVideoManagementItem ToQuestionVideoItem(QuestionVideo item)
|
||||
{
|
||||
return new QuestionVideoManagementItem(
|
||||
item.Id,
|
||||
item.QuestionId,
|
||||
item.VideoId,
|
||||
item.LegacyId,
|
||||
item.VideoType,
|
||||
item.SortOrder,
|
||||
item.Metadata);
|
||||
}
|
||||
|
||||
private static OperationContentItem ToOperationItem(Banner item)
|
||||
{
|
||||
return new OperationContentItem(
|
||||
item.Id,
|
||||
"banners",
|
||||
item.RegionId,
|
||||
null,
|
||||
item.LegacyId,
|
||||
item.Title,
|
||||
item.Content,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
item.SortOrder,
|
||||
item.IsActive,
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
item.Subtitle,
|
||||
item.ButtonText,
|
||||
item.ButtonLink,
|
||||
item.BackgroundColor,
|
||||
item.BorderColor
|
||||
}));
|
||||
}
|
||||
|
||||
private static OperationContentItem ToOperationItem(Faq item)
|
||||
{
|
||||
return new OperationContentItem(
|
||||
item.Id,
|
||||
"faqs",
|
||||
item.RegionId,
|
||||
null,
|
||||
item.LegacyId,
|
||||
null,
|
||||
null,
|
||||
item.Question,
|
||||
item.Answer,
|
||||
null,
|
||||
null,
|
||||
item.SortOrder,
|
||||
item.IsActive,
|
||||
JsonDefaults.Object());
|
||||
}
|
||||
|
||||
private static OperationContentItem ToOperationItem(Announcement item)
|
||||
{
|
||||
return new OperationContentItem(
|
||||
item.Id,
|
||||
"announcements",
|
||||
null,
|
||||
null,
|
||||
item.LegacyId,
|
||||
null,
|
||||
item.Content,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
item.SortOrder,
|
||||
item.IsActive,
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
item.Link,
|
||||
item.BackgroundColor
|
||||
}));
|
||||
}
|
||||
|
||||
private static OperationContentItem ToOperationItem(ExamDate item)
|
||||
{
|
||||
return new OperationContentItem(
|
||||
item.Id,
|
||||
"exam-dates",
|
||||
item.RegionId,
|
||||
item.SchoolId,
|
||||
item.LegacyId,
|
||||
item.ExamName,
|
||||
item.Description,
|
||||
null,
|
||||
null,
|
||||
item.ExamAt,
|
||||
item.ExamType,
|
||||
item.SortOrder,
|
||||
item.IsActive,
|
||||
item.Metadata);
|
||||
}
|
||||
|
||||
private static ContentImportJobItem ToJobItem(ContentImportJob job)
|
||||
{
|
||||
return new ContentImportJobItem(
|
||||
job.Id,
|
||||
job.TargetRegionId,
|
||||
job.TargetSubjectId,
|
||||
job.TargetCategoryId,
|
||||
job.TargetContentNodeId,
|
||||
job.TargetQuestionBankId,
|
||||
job.ImportType,
|
||||
job.SourceFormat,
|
||||
job.Status,
|
||||
job.SourceName,
|
||||
job.SourceHash,
|
||||
job.DryRun,
|
||||
job.TotalCount,
|
||||
job.ValidCount,
|
||||
job.ErrorCount,
|
||||
job.WarningCount,
|
||||
job.InsertedCount,
|
||||
job.UpdatedCount,
|
||||
job.SkippedCount,
|
||||
job.Summary,
|
||||
job.ErrorMessage,
|
||||
job.StartedAt,
|
||||
job.FinishedAt,
|
||||
job.CreatedAt,
|
||||
job.UpdatedAt);
|
||||
}
|
||||
|
||||
private static ContentImportItemModel ToImportItem(ContentImportItem item)
|
||||
{
|
||||
return new ContentImportItemModel(
|
||||
item.Id,
|
||||
item.JobId,
|
||||
item.RowNo,
|
||||
item.ExternalId,
|
||||
item.Status,
|
||||
item.TargetType,
|
||||
item.TargetId,
|
||||
item.SourcePayload,
|
||||
item.NormalizedPayload,
|
||||
item.ContentHash,
|
||||
item.IssuesCount);
|
||||
}
|
||||
|
||||
private async Task<CurrentDataScope> RequireDataScopeAsync(
|
||||
DirectContentActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
if (!access.IsCurrentTenantMember ||
|
||||
access.UserId != actor.UserId ||
|
||||
access.TenantId != actor.TenantId ||
|
||||
!ContentPermissions.Any(access.HasTenantPermission))
|
||||
{
|
||||
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
|
||||
}
|
||||
|
||||
return access.DataScope;
|
||||
}
|
||||
|
||||
private static void EnsureRegionWriteAllowed(
|
||||
CurrentDataScope scope,
|
||||
DirectContentActor actor,
|
||||
Guid? currentRegionId,
|
||||
Guid? targetRegionId,
|
||||
bool isNew,
|
||||
string notFoundCode)
|
||||
{
|
||||
var canAccessCurrent = isNew || scope.AllowsResource(actor.UserId, regionId: currentRegionId);
|
||||
var canAccessTarget = scope.AllowsResource(actor.UserId, regionId: targetRegionId);
|
||||
if (!canAccessCurrent || !canAccessTarget)
|
||||
{
|
||||
throw new ContentManagementException("Content resource was not found.", notFoundCode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TEntity?> ResolveByIdOrLegacyAsync<TEntity>(
|
||||
DbSet<TEntity> set,
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string? legacyId,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : AuditableTenantEntity
|
||||
{
|
||||
if (id.HasValue)
|
||||
{
|
||||
return await set.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == id.Value, cancellationToken);
|
||||
}
|
||||
|
||||
var normalizedLegacyId = Normalize(legacyId);
|
||||
return normalizedLegacyId is null
|
||||
? null
|
||||
: await set.SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && EF.Property<string?>(item, "LegacyId") == normalizedLegacyId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AssertReferenceAsync<TEntity>(
|
||||
Guid tenantId,
|
||||
Guid? id,
|
||||
string code,
|
||||
CancellationToken cancellationToken)
|
||||
where TEntity : class
|
||||
{
|
||||
if (!id.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var exists = await dbContext.Set<TEntity>()
|
||||
.AnyAsync(item => EF.Property<Guid>(item, "TenantId") == tenantId && EF.Property<Guid>(item, "Id") == id.Value, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Referenced entity was not found.", code);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertImportJobAsync(Guid tenantId, Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.ContentImportJobs.AnyAsync(
|
||||
item => item.TenantId == tenantId && item.Id == jobId,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeOperationKind(string kind)
|
||||
{
|
||||
var normalized = Normalize(kind)?.ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"banner" or "banners" => "banners",
|
||||
"faq" or "faqs" => "faqs",
|
||||
"announcement" or "announcements" => "announcements",
|
||||
"exam-date" or "exam-dates" or "examdates" => "exam-dates",
|
||||
_ => normalized ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static ContentImportType ParseImportType(string value)
|
||||
{
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
"questions" => ContentImportType.Questions,
|
||||
"vocabulary" => ContentImportType.Vocabulary,
|
||||
"handbook" => ContentImportType.Handbook,
|
||||
"scoreline" => ContentImportType.Scoreline,
|
||||
"videos" => ContentImportType.Videos,
|
||||
_ => throw new ContentManagementException("Import type is invalid.", "import_type_invalid")
|
||||
};
|
||||
}
|
||||
|
||||
private static TEnum Parse<TEnum>(string? value, TEnum fallback, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new ContentManagementException("Enum value is invalid.", code);
|
||||
}
|
||||
|
||||
private static TEnum? ParseNullable<TEnum>(string? value, string code)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<TEnum>(value.Trim(), ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new ContentManagementException("Enum value is invalid.", code);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
return !limit.HasValue || limit <= 0 ? DefaultLimit : Math.Min(limit.Value, MaxLimit);
|
||||
}
|
||||
|
||||
private static JsonElement JsonObjectOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind is JsonValueKind.Object ? value : JsonDefaults.Object();
|
||||
}
|
||||
|
||||
private static JsonElement JsonArrayOrDefault(JsonElement value)
|
||||
{
|
||||
return value.ValueKind is JsonValueKind.Array ? value : JsonDefaults.Array();
|
||||
}
|
||||
|
||||
private static JsonElement GetElement(JsonElement payload, string name, JsonElement fallback)
|
||||
{
|
||||
return payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String ? Normalize(value.GetString()) : value.ToString();
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)
|
||||
? number
|
||||
: int.TryParse(value.ToString(), out number)
|
||||
? number
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Guid? GetGuid(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String &&
|
||||
Guid.TryParse(value.GetString(), out var guid)
|
||||
? guid
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool? GetBool(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class DirectContentService
|
||||
{
|
||||
private async Task<SimpleImportResult> CreateImportJobAsync(
|
||||
DirectContentActor actor,
|
||||
SimpleImportCommand command,
|
||||
bool execute,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SupportedImportTypes.Contains(command.ImportType))
|
||||
{
|
||||
throw new ContentManagementException("Import type is invalid.", "import_type_invalid");
|
||||
}
|
||||
|
||||
var importType = ParseImportType(command.ImportType);
|
||||
var sourceFormat = Parse(command.SourceFormat, ImportSourceFormat.Json, "import_source_format_invalid");
|
||||
var items = command.Items.Select(item => item.ValueKind == JsonValueKind.Undefined ? JsonDefaults.Object() : item).ToArray();
|
||||
var job = new ContentImportJob
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CreatedBy = actor.UserId,
|
||||
TargetRegionId = command.RegionId,
|
||||
TargetSubjectId = command.SubjectId,
|
||||
TargetCategoryId = command.CategoryId,
|
||||
TargetContentNodeId = command.ContentNodeId,
|
||||
TargetQuestionBankId = command.QuestionBankId,
|
||||
ImportType = importType,
|
||||
SourceFormat = sourceFormat,
|
||||
Status = execute ? ContentImportStatus.Completed : ContentImportStatus.Preview,
|
||||
SourceName = Normalize(command.SourceName),
|
||||
DryRun = command.DryRun,
|
||||
TotalCount = items.Length,
|
||||
ValidCount = items.Length,
|
||||
RawPayload = JsonSerializer.SerializeToElement(items),
|
||||
NormalizedPayload = JsonSerializer.SerializeToElement(items),
|
||||
StartedAt = execute ? DateTimeOffset.UtcNow : null,
|
||||
FinishedAt = execute ? DateTimeOffset.UtcNow : null
|
||||
};
|
||||
dbContext.ContentImportJobs.Add(job);
|
||||
|
||||
var importItems = new List<ContentImportItem>();
|
||||
var rowNo = 1;
|
||||
foreach (var payload in items)
|
||||
{
|
||||
var importItem = new ContentImportItem
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
JobId = job.Id,
|
||||
RowNo = rowNo++,
|
||||
ExternalId = GetString(payload, "legacyId") ?? GetString(payload, "id"),
|
||||
Status = execute ? ContentImportItemStatus.Inserted : ContentImportItemStatus.Valid,
|
||||
SourcePayload = payload,
|
||||
NormalizedPayload = payload
|
||||
};
|
||||
|
||||
if (execute)
|
||||
{
|
||||
var target = await WriteImportedItemAsync(actor, command, payload, cancellationToken);
|
||||
importItem.TargetType = target.TargetType;
|
||||
importItem.TargetId = target.TargetId;
|
||||
job.InsertedCount++;
|
||||
}
|
||||
|
||||
importItems.Add(importItem);
|
||||
}
|
||||
|
||||
dbContext.ContentImportItems.AddRange(importItems);
|
||||
job.Summary = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
mode = execute ? "execute" : "preview",
|
||||
supportedTypes = SupportedImportTypes,
|
||||
note = "Synchronous direct migration import skeleton; async worker will be introduced later."
|
||||
});
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new SimpleImportResult(
|
||||
ToJobItem(job),
|
||||
importItems.Select(ToImportItem).ToArray(),
|
||||
[]);
|
||||
}
|
||||
|
||||
private async Task<(string TargetType, Guid TargetId)> WriteImportedItemAsync(
|
||||
DirectContentActor actor,
|
||||
SimpleImportCommand command,
|
||||
JsonElement payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
switch (command.ImportType.ToLowerInvariant())
|
||||
{
|
||||
case "questions":
|
||||
var result = await CreateQuestionAsync(actor, new QuestionWriteCommand(
|
||||
null,
|
||||
command.QuestionBankId,
|
||||
command.SubjectId,
|
||||
command.CategoryId,
|
||||
null,
|
||||
command.EntryId,
|
||||
command.ContentNodeId,
|
||||
command.CollectionId,
|
||||
GetString(payload, "legacyId"),
|
||||
GetString(payload, "type") ?? "choice",
|
||||
GetString(payload, "typeLabel"),
|
||||
GetInt(payload, "difficulty"),
|
||||
GetElement(payload, "tags", JsonDefaults.Array()),
|
||||
GetString(payload, "content") ?? GetString(payload, "title"),
|
||||
GetElement(payload, "options", JsonDefaults.Array()),
|
||||
GetInt(payload, "correctOptionIndex"),
|
||||
GetElement(payload, "correctOptionIndices", JsonDefaults.Array()),
|
||||
GetString(payload, "answerText") ?? GetString(payload, "answer"),
|
||||
GetString(payload, "explanation"),
|
||||
GetElement(payload, "subQuestions", JsonDefaults.Array()),
|
||||
GetString(payload, "codeLang"),
|
||||
GetString(payload, "codeTemplate"),
|
||||
GetString(payload, "mediaUrl"),
|
||||
"Published",
|
||||
GetElement(payload, "examMarkers", JsonDefaults.Object()),
|
||||
GetString(payload, "sourceHash"),
|
||||
true), cancellationToken);
|
||||
return ("question", result.Item.Id);
|
||||
case "vocabulary":
|
||||
var word = await UpsertVocabularyWordAsync(actor, new VocabularyWordCommand(
|
||||
null,
|
||||
null,
|
||||
command.EntryId,
|
||||
command.ContentNodeId,
|
||||
GetString(payload, "legacyId"),
|
||||
GetString(payload, "word") ?? GetString(payload, "name") ?? "未命名单词",
|
||||
GetString(payload, "phonetic"),
|
||||
GetString(payload, "meaning"),
|
||||
GetString(payload, "example"),
|
||||
GetString(payload, "exampleTranslation"),
|
||||
GetInt(payload, "difficulty"),
|
||||
GetElement(payload, "tags", JsonDefaults.Array()),
|
||||
GetInt(payload, "order"),
|
||||
true,
|
||||
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
|
||||
return ("vocabulary_word", word.Item.Id);
|
||||
case "handbook":
|
||||
var entry = await UpsertHandbookEntryAsync(actor, new HandbookEntryCommand(
|
||||
null,
|
||||
null,
|
||||
command.EntryId,
|
||||
command.ContentNodeId,
|
||||
GetString(payload, "legacyId"),
|
||||
GetString(payload, "title") ?? GetString(payload, "name") ?? "未命名条目",
|
||||
GetString(payload, "summary"),
|
||||
GetString(payload, "content"),
|
||||
GetElement(payload, "tags", JsonDefaults.Array()),
|
||||
GetInt(payload, "order"),
|
||||
true,
|
||||
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
|
||||
return ("handbook_entry", entry.Item.Id);
|
||||
case "scoreline":
|
||||
var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand(
|
||||
null,
|
||||
command.RegionId,
|
||||
GetGuid(payload, "schoolId"),
|
||||
GetGuid(payload, "majorId"),
|
||||
GetString(payload, "legacyId"),
|
||||
GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year,
|
||||
GetString(payload, "schoolName"),
|
||||
GetString(payload, "majorName"),
|
||||
GetElement(payload, "fieldValues", payload)), cancellationToken);
|
||||
return ("scoreline_record", scoreline.Item.Id);
|
||||
case "videos":
|
||||
var video = await UpsertVideoAsync(actor, new VideoExplanationCommand(
|
||||
null,
|
||||
command.SubjectId,
|
||||
GetString(payload, "legacyId"),
|
||||
GetString(payload, "title") ?? "未命名视频",
|
||||
GetString(payload, "description"),
|
||||
GetString(payload, "videoUrl") ?? GetString(payload, "url"),
|
||||
GetString(payload, "thumbnailUrl"),
|
||||
GetInt(payload, "durationSeconds"),
|
||||
GetElement(payload, "knowledgeTags", JsonDefaults.Array()),
|
||||
GetBool(payload, "isGeneral"),
|
||||
GetInt(payload, "difficulty"),
|
||||
GetInt(payload, "order"),
|
||||
true,
|
||||
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
|
||||
return ("video_explanation", video.Item.Id);
|
||||
default:
|
||||
throw new ContentManagementException("Import type is invalid.", "import_type_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Banner> UpsertBannerAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Banners, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new Banner { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Title = Normalize(command.Title);
|
||||
item.Subtitle = Normalize(command.Subtitle);
|
||||
item.Content = Normalize(command.Content);
|
||||
item.ButtonText = Normalize(command.ButtonText);
|
||||
item.ButtonLink = Normalize(command.ButtonLink);
|
||||
item.BackgroundColor = Normalize(command.BackgroundColor);
|
||||
item.BorderColor = Normalize(command.BorderColor);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Banners.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<Faq> UpsertFaqAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Faqs, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new Faq { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Question = Normalize(command.Question) ?? Normalize(command.Title);
|
||||
item.Answer = Normalize(command.Answer) ?? Normalize(command.Content);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Faqs.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<Announcement> UpsertAnnouncementAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.Announcements, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new Announcement { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Content = Normalize(command.Content) ?? Normalize(command.Title);
|
||||
item.Link = Normalize(command.Link);
|
||||
item.BackgroundColor = Normalize(command.BackgroundColor);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.Announcements.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
private async Task<ExamDate> UpsertExamDateAsync(DirectContentActor actor, OperationContentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.ExamName);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ExamDates, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new ExamDate { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.SchoolId = command.SchoolId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.ExamName = command.ExamName.Trim();
|
||||
item.ExamAt = command.ExamAt;
|
||||
item.ExamType = Normalize(command.ExamType);
|
||||
item.Description = Normalize(command.Description) ?? Normalize(command.Content);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ExamDates.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static void ApplyQuestion(Question question, QuestionWriteCommand command)
|
||||
{
|
||||
question.QuestionBankId = command.QuestionBankId;
|
||||
question.SubjectId = command.SubjectId;
|
||||
question.CategoryId = command.CategoryId;
|
||||
question.NodeId = command.NodeId;
|
||||
question.EntryId = command.EntryId;
|
||||
question.ContentNodeId = command.ContentNodeId;
|
||||
question.PrimaryCollectionId = command.PrimaryCollectionId;
|
||||
question.LegacyId = Normalize(command.LegacyId);
|
||||
question.Type = Normalize(command.Type) ?? question.Type;
|
||||
question.TypeLabel = Normalize(command.TypeLabel);
|
||||
question.Difficulty = command.Difficulty;
|
||||
question.Tags = JsonArrayOrDefault(command.Tags);
|
||||
question.ExamMarkers = JsonObjectOrDefault(command.ExamMarkers);
|
||||
question.MediaUrl = Normalize(command.MediaUrl);
|
||||
question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
|
||||
}
|
||||
|
||||
private static void ValidateQuestionForPublication(QuestionWriteCommand command)
|
||||
{
|
||||
var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
|
||||
if (status != QuestionStatus.Published)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var type = Normalize(command.Type) ?? "choice";
|
||||
if (!QuestionGrader.HasValidAuthoritativeAnswer(
|
||||
type,
|
||||
command.CorrectOptionIndex,
|
||||
command.CorrectOptionIndices,
|
||||
command.AnswerText))
|
||||
{
|
||||
throw new ContentManagementException(
|
||||
"Published questions require a valid authoritative answer.",
|
||||
"question_grading_rule_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static QuestionVersion BuildQuestionVersion(
|
||||
DirectContentActor actor,
|
||||
Guid questionId,
|
||||
int versionNo,
|
||||
QuestionWriteCommand command)
|
||||
{
|
||||
var version = new QuestionVersion
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
QuestionId = questionId,
|
||||
VersionNo = versionNo,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
ApplyQuestionVersion(version, command);
|
||||
return version;
|
||||
}
|
||||
|
||||
private static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command)
|
||||
{
|
||||
version.Content = Normalize(command.Content);
|
||||
version.Options = JsonArrayOrDefault(command.Options);
|
||||
version.CorrectOptionIndex = command.CorrectOptionIndex;
|
||||
version.CorrectOptionIndices = JsonArrayOrDefault(command.CorrectOptionIndices);
|
||||
version.AnswerText = Normalize(command.AnswerText);
|
||||
version.Explanation = Normalize(command.Explanation);
|
||||
version.SubQuestions = JsonArrayOrDefault(command.SubQuestions);
|
||||
version.CodeLang = Normalize(command.CodeLang);
|
||||
version.CodeTemplate = Normalize(command.CodeTemplate);
|
||||
version.SourceHash = Normalize(command.SourceHash);
|
||||
}
|
||||
|
||||
private async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await AssertReferenceAsync<QuestionBank>(tenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Subject>(tenantId, command.SubjectId, "subject_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Category>(tenantId, command.CategoryId, "category_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ModuleNode>(tenantId, command.NodeId, "module_node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(tenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(tenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<QuestionCollection>(tenantId, command.PrimaryCollectionId, "collection_not_found", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task SyncPrimaryCollectionItemAsync(
|
||||
DirectContentActor actor,
|
||||
Question question,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!question.PrimaryCollectionId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await dbContext.QuestionCollectionItems.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.CollectionId == question.PrimaryCollectionId.Value &&
|
||||
item.QuestionId == question.Id,
|
||||
cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
new QuestionLocator(QuestionSource.Tenant, question.Id),
|
||||
cancellationToken);
|
||||
var nextOrder = await dbContext.QuestionCollectionItems
|
||||
.Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value)
|
||||
.Select(item => (int?)item.SortOrder)
|
||||
.MaxAsync(cancellationToken) ?? -1;
|
||||
dbContext.QuestionCollectionItems.Add(new QuestionCollectionItem
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CollectionId = question.PrimaryCollectionId.Value,
|
||||
QuestionReferenceId = reference.Id,
|
||||
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
|
||||
QuestionId = question.Id,
|
||||
SortOrder = nextOrder + 1
|
||||
});
|
||||
}
|
||||
|
||||
var collection = await dbContext.QuestionCollections.SingleAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value,
|
||||
cancellationToken);
|
||||
collection.QuestionCount = await dbContext.QuestionCollectionItems.CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value,
|
||||
cancellationToken) + (existing is null ? 1 : 0);
|
||||
collection.UpdatedBy = actor.UserId;
|
||||
}
|
||||
|
||||
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync(
|
||||
Guid tenantId,
|
||||
Guid? unitId,
|
||||
Guid? entryId,
|
||||
Guid? contentNodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!unitId.HasValue)
|
||||
{
|
||||
return (entryId, contentNodeId);
|
||||
}
|
||||
|
||||
var unit = await dbContext.VocabularyUnits.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == unitId.Value,
|
||||
cancellationToken);
|
||||
if (unit is null)
|
||||
{
|
||||
throw new ContentManagementException("Vocabulary unit was not found.", "vocabulary_unit_not_found");
|
||||
}
|
||||
|
||||
return (entryId ?? unit.EntryId, contentNodeId ?? unit.ContentNodeId);
|
||||
}
|
||||
|
||||
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookSubjectNavigationAsync(
|
||||
Guid tenantId,
|
||||
Guid? subjectId,
|
||||
Guid? entryId,
|
||||
Guid? contentNodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!subjectId.HasValue)
|
||||
{
|
||||
return (entryId, contentNodeId);
|
||||
}
|
||||
|
||||
var subject = await dbContext.HandbookSubjects.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == subjectId.Value,
|
||||
cancellationToken);
|
||||
if (subject is null)
|
||||
{
|
||||
throw new ContentManagementException("Handbook subject was not found.", "handbook_subject_not_found");
|
||||
}
|
||||
|
||||
return (entryId ?? subject.EntryId, contentNodeId ?? subject.ContentNodeId);
|
||||
}
|
||||
|
||||
private async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveHandbookChapterNavigationAsync(
|
||||
Guid tenantId,
|
||||
Guid? chapterId,
|
||||
Guid? entryId,
|
||||
Guid? contentNodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!chapterId.HasValue)
|
||||
{
|
||||
return (entryId, contentNodeId);
|
||||
}
|
||||
|
||||
var chapter = await dbContext.HandbookChapters.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == chapterId.Value,
|
||||
cancellationToken);
|
||||
if (chapter is null)
|
||||
{
|
||||
throw new ContentManagementException("Handbook chapter was not found.", "handbook_chapter_not_found");
|
||||
}
|
||||
|
||||
return (entryId ?? chapter.EntryId, contentNodeId ?? chapter.ContentNodeId);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class DirectContentService
|
||||
{
|
||||
public async Task<CatalogList<HandbookSubject>> GetHandbookSubjectsAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
var regionIds = scope.RegionIds.ToArray();
|
||||
var query = dbContext.HandbookSubjects.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.ApplyDataScope(scope, null, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value));
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
|
||||
if (filter.MajorId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.MajorId == filter.MajorId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<HandbookSubject>(await query
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<HandbookSubject>> UpsertHandbookSubjectAsync(
|
||||
DirectContentActor actor,
|
||||
HandbookSubjectCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var scope = await RequireDataScopeAsync(actor, cancellationToken);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookSubjects, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
EnsureRegionWriteAllowed(scope, actor, item?.RegionId, command.RegionId, isNew, "handbook_subject_not_found");
|
||||
item ??= new HandbookSubject { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.SchoolId = command.SchoolId;
|
||||
item.MajorId = command.MajorId;
|
||||
item.EntryId = command.EntryId;
|
||||
item.ContentNodeId = command.ContentNodeId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Name = command.Name.Trim();
|
||||
item.Type = ParseNullable<HandbookSubjectType>(command.Type, "handbook_subject_type_invalid");
|
||||
item.Icon = Normalize(command.Icon);
|
||||
item.Color = Normalize(command.Color);
|
||||
item.Description = Normalize(command.Description);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.HandbookSubjects.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<HandbookSubject>(item);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<HandbookChapter>> GetHandbookChaptersAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.HandbookChapters.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.SubjectId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SubjectId == filter.SubjectId.Value);
|
||||
}
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Name.Contains(keyword) || (item.Description != null && item.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<HandbookChapter>(await query
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Name)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<HandbookChapter>> UpsertHandbookChapterAsync(
|
||||
DirectContentActor actor,
|
||||
HandbookChapterCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Name);
|
||||
await AssertReferenceAsync<HandbookSubject>(actor.TenantId, command.SubjectId, "handbook_subject_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookChapters, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new HandbookChapter { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
var chapterNavigation = await ResolveHandbookSubjectNavigationAsync(
|
||||
actor.TenantId,
|
||||
command.SubjectId,
|
||||
command.EntryId,
|
||||
command.ContentNodeId,
|
||||
cancellationToken);
|
||||
item.SubjectId = command.SubjectId;
|
||||
item.EntryId = chapterNavigation.EntryId;
|
||||
item.ContentNodeId = chapterNavigation.ContentNodeId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Name = command.Name.Trim();
|
||||
item.Description = Normalize(command.Description);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.HandbookChapters.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<HandbookChapter>(item);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<HandbookEntry>> GetHandbookEntriesAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.HandbookEntries.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.ChapterId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ChapterId == filter.ChapterId.Value);
|
||||
}
|
||||
|
||||
if (filter.EntryId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.EntryId == filter.EntryId.Value);
|
||||
}
|
||||
|
||||
if (filter.ContentNodeId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.ContentNodeId == filter.ContentNodeId.Value);
|
||||
}
|
||||
|
||||
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(item => item.IsActive);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.Title.Contains(keyword) || (item.Content != null && item.Content.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<HandbookEntry>(await query
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Title)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<HandbookEntry>> UpsertHandbookEntryAsync(
|
||||
DirectContentActor actor,
|
||||
HandbookEntryCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.Title);
|
||||
await AssertReferenceAsync<HandbookChapter>(actor.TenantId, command.ChapterId, "handbook_chapter_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentEntry>(actor.TenantId, command.EntryId, "entry_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<ContentNode>(actor.TenantId, command.ContentNodeId, "node_not_found", cancellationToken);
|
||||
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.HandbookEntries, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new HandbookEntry { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
var entryNavigation = await ResolveHandbookChapterNavigationAsync(
|
||||
actor.TenantId,
|
||||
command.ChapterId,
|
||||
command.EntryId,
|
||||
command.ContentNodeId,
|
||||
cancellationToken);
|
||||
item.ChapterId = command.ChapterId;
|
||||
item.EntryId = entryNavigation.EntryId;
|
||||
item.ContentNodeId = entryNavigation.ContentNodeId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Title = command.Title.Trim();
|
||||
item.Summary = Normalize(command.Summary);
|
||||
item.Content = Normalize(command.Content);
|
||||
item.Tags = JsonArrayOrDefault(command.Tags);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
item.IsActive = command.IsActive ?? item.IsActive;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.HandbookEntries.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<HandbookEntry>(item);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed partial class DirectContentService
|
||||
{
|
||||
public Task<SimpleImportResult> PreviewImportAsync(
|
||||
DirectContentActor actor,
|
||||
SimpleImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CreateImportJobAsync(actor, command with { DryRun = true }, execute: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<SimpleImportResult> ExecuteImportAsync(
|
||||
DirectContentActor actor,
|
||||
SimpleImportCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ContentImportJobDetail> GetImportJobAsync(
|
||||
DirectContentActor actor,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = await dbContext.ContentImportJobs.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
|
||||
.Select(item => ToJobItem(item))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
|
||||
var items = await dbContext.ContentImportItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
|
||||
.OrderBy(item => item.RowNo)
|
||||
.Take(MaxLimit)
|
||||
.Select(item => ToImportItem(item))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var issues = await dbContext.ContentImportIssues.AsNoTracking()
|
||||
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
|
||||
.OrderBy(issue => issue.RowNo)
|
||||
.ThenBy(issue => issue.CreatedAt)
|
||||
.Take(MaxLimit)
|
||||
.Select(issue => new ContentImportIssueModel(
|
||||
issue.Id,
|
||||
issue.JobId,
|
||||
issue.ItemId,
|
||||
issue.RowNo,
|
||||
issue.Severity,
|
||||
issue.Code,
|
||||
issue.FieldPath,
|
||||
issue.Message,
|
||||
issue.Details))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new ContentImportJobDetail(job, items, issues);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentImportIssueModel>> GetImportIssuesAsync(
|
||||
DirectContentActor actor,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertImportJobAsync(actor.TenantId, jobId, cancellationToken);
|
||||
var issues = await dbContext.ContentImportIssues.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
|
||||
.OrderBy(item => item.RowNo)
|
||||
.ThenBy(item => item.CreatedAt)
|
||||
.Take(MaxLimit)
|
||||
.Select(item => new ContentImportIssueModel(
|
||||
item.Id,
|
||||
item.JobId,
|
||||
item.ItemId,
|
||||
item.RowNo,
|
||||
item.Severity,
|
||||
item.Code,
|
||||
item.FieldPath,
|
||||
item.Message,
|
||||
item.Details))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<ContentImportIssueModel>(issues);
|
||||
}
|
||||
|
||||
public async Task<ImportPostCheckResult> RunImportPostCheckAsync(
|
||||
DirectContentActor actor,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = await dbContext.ContentImportJobs.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == jobId,
|
||||
cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
|
||||
var counts = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
job.TotalCount,
|
||||
job.ValidCount,
|
||||
job.ErrorCount,
|
||||
job.WarningCount,
|
||||
job.InsertedCount,
|
||||
job.UpdatedCount,
|
||||
job.SkippedCount
|
||||
});
|
||||
job.Summary = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
postCheck = new
|
||||
{
|
||||
status = job.ErrorCount == 0 ? "passed" : "warning",
|
||||
checkedAt = DateTimeOffset.UtcNow,
|
||||
counts
|
||||
}
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, []);
|
||||
}
|
||||
|
||||
public async Task<ImportPostCheckResult> GetImportPostCheckAsync(
|
||||
DirectContentActor actor,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == jobId,
|
||||
cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
|
||||
var issues = await GetImportIssuesAsync(actor, jobId, cancellationToken);
|
||||
var counts = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
job.TotalCount,
|
||||
job.ValidCount,
|
||||
job.ErrorCount,
|
||||
job.WarningCount,
|
||||
job.InsertedCount,
|
||||
job.UpdatedCount,
|
||||
job.SkippedCount
|
||||
});
|
||||
return new ImportPostCheckResult(job.Id, job.ErrorCount == 0 ? "passed" : "warning", counts, issues.Items);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user