refactor(architecture): enforce module boundaries

This commit is contained in:
2026-08-03 12:26:09 +08:00
parent 290a0c7bd7
commit caea0062b0
234 changed files with 21723 additions and 18117 deletions

View File

@@ -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>
## 运行时边界

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("新增或更新推荐团队关系")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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; } = [];
}

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

View File

@@ -0,0 +1,6 @@
namespace Tiku.Application.Backoffice;
public sealed class BackofficeException(string message, string code) : InvalidOperationException(message)
{
public string Code { get; } = code;
}

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

View File

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

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace Tiku.Application.Profile;
public sealed class ProfileException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

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

View File

@@ -0,0 +1,6 @@
namespace Tiku.Application.Scoreline;
public sealed class ScorelineQueryException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

View File

@@ -488,8 +488,3 @@ internal sealed class BackofficeService(
}
}
public sealed class BackofficeException(string message, string code) : InvalidOperationException(message)
{
public string Code { get; } = code;
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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