feat: 完成租户个人中心 API 首版实现
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,10 +1,19 @@
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
bin/
|
||||
obj/
|
||||
**/bin/
|
||||
**/obj/
|
||||
.claude/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
*.suo
|
||||
*.user
|
||||
packages/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj.user
|
||||
|
||||
# 保留根目录 scripts 目录提交
|
||||
|
||||
245
src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs
Normal file
245
src/Api/TakeoutSaaS.TenantApi/Controllers/PersonalController.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户端个人中心。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 提供个人总览、角色概览、配额、账单、支付、操作记录与消息摘要能力。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/v{version:apiVersion}/personal")]
|
||||
public sealed class PersonalController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取个人中心总览。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>总览结果。</returns>
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalOverviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalOverviewDto>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<PersonalOverviewDto>> GetOverview(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询总览
|
||||
var overview = await mediator.Send(new GetPersonalOverviewQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalOverviewDto>.Ok(overview);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取我的角色与权限概览。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限概览。</returns>
|
||||
[HttpGet("roles")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalRolePermissionSummaryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalRolePermissionSummaryDto>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<PersonalRolePermissionSummaryDto>> GetRoles(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询角色权限概览
|
||||
var summary = await mediator.Send(new GetPersonalRolesQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalRolePermissionSummaryDto>.Ok(summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取套餐与配额摘要。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额摘要。</returns>
|
||||
[HttpGet("quota")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalQuotaUsageSummaryDto>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<PersonalQuotaUsageSummaryDto>> GetQuota(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询配额摘要
|
||||
var summary = await mediator.Send(new GetPersonalQuotaQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalQuotaUsageSummaryDto>.Ok(summary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询账单记录。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单分页结果。</returns>
|
||||
[HttpGet("billing/statements")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalBillingStatementDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalBillingStatementDto>>> SearchBillingStatements(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalBillingStatementsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
From = from,
|
||||
To = to
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalBillingStatementDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询支付记录。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录分页结果。</returns>
|
||||
[HttpGet("billing/payments")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalPaymentRecordDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalPaymentRecordDto>>> SearchPayments(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalPaymentsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
From = from,
|
||||
To = to
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalPaymentRecordDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取账单/配额可见角色配置。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>可见角色配置。</returns>
|
||||
[HttpGet("visibility/roles")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status403Forbidden)]
|
||||
public async Task<ApiResponse<PersonalVisibilityRoleConfigDto>> GetVisibilityRoles(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询配置
|
||||
var config = await mediator.Send(new GetPersonalVisibilityRoleConfigQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalVisibilityRoleConfigDto>.Ok(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新账单/配额可见角色配置。
|
||||
/// </summary>
|
||||
/// <param name="command">更新请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的配置。</returns>
|
||||
[HttpPut("visibility/roles")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PersonalVisibilityRoleConfigDto>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PersonalVisibilityRoleConfigDto>> UpdateVisibilityRoles(
|
||||
[FromBody] UpdatePersonalVisibilityRoleConfigCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 更新配置
|
||||
var config = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PersonalVisibilityRoleConfigDto>.Ok(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人操作记录。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数(默认 50,最大 50)。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>操作记录分页结果。</returns>
|
||||
[HttpGet("operations")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalOperationLogDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalOperationLogDto>>> SearchOperations(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalOperationsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
From = from,
|
||||
To = to
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalOperationLogDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人消息摘要。
|
||||
/// </summary>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="unreadOnly">是否仅返回未读。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>消息摘要分页结果。</returns>
|
||||
[HttpGet("notifications")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PersonalNotificationDto>>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ApiResponse<PagedResult<PersonalNotificationDto>>> SearchNotifications(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] bool unreadOnly = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 发送查询
|
||||
var result = await mediator.Send(new SearchPersonalNotificationsQuery
|
||||
{
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
UnreadOnly = unreadOnly
|
||||
}, cancellationToken);
|
||||
|
||||
// 2. 返回分页结果
|
||||
return ApiResponse<PagedResult<PersonalNotificationDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
using TakeoutSaaS.Application.App.Common.Behaviors;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Extensions;
|
||||
|
||||
@@ -22,6 +24,13 @@ public static class AppApplicationServiceCollectionExtensions
|
||||
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
|
||||
// 1. 注册个人中心基础服务
|
||||
services.AddScoped<PersonalContextService>();
|
||||
services.AddSingleton<PersonalMaskingService>();
|
||||
services.AddSingleton<PersonalDateRangeValidator>();
|
||||
services.AddScoped<PersonalModuleStatusService>();
|
||||
services.AddScoped<PersonalAuditService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心账号信息 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalAccountProfileDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
public string? AvatarUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏手机号。
|
||||
/// </summary>
|
||||
public string? PhoneMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏邮箱。
|
||||
/// </summary>
|
||||
public string? EmailMasked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime RegisteredAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心账单记录 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalBillingStatementDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单周期开始日期。
|
||||
/// </summary>
|
||||
public DateOnly BillingPeriodStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单周期结束日期。
|
||||
/// </summary>
|
||||
public DateOnly BillingPeriodEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 应付金额。
|
||||
/// </summary>
|
||||
public decimal AmountDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实付金额。
|
||||
/// </summary>
|
||||
public decimal AmountPaid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 账单状态。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 到期时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime DueAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心子模块执行状态 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalModuleStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模块名称。
|
||||
/// </summary>
|
||||
public string Module { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模块状态:ok / degraded / failed / timeout / skipped。
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "ok";
|
||||
|
||||
/// <summary>
|
||||
/// 业务错误码。
|
||||
/// </summary>
|
||||
public string? ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误说明。
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 追踪标识。
|
||||
/// </summary>
|
||||
public string? TraceId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心消息摘要 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalNotificationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 通知 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long NotificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类别。
|
||||
/// </summary>
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否已读。
|
||||
/// </summary>
|
||||
public bool IsRead { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发送时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime SentAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心操作记录 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalOperationLogDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 操作记录 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long OperationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作人用户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long OperatorUserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作类型。
|
||||
/// </summary>
|
||||
public string ActionType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
public string TargetType { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 目标 ID。
|
||||
/// </summary>
|
||||
public string? TargetId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否成功。
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 发生时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心总览 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalOverviewDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 请求追踪标识。
|
||||
/// </summary>
|
||||
public string RequestId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 总体状态:success / partial_success / failure。
|
||||
/// </summary>
|
||||
public string OverallStatus { get; init; } = "success";
|
||||
|
||||
/// <summary>
|
||||
/// 账号信息。
|
||||
/// </summary>
|
||||
public PersonalAccountProfileDto? AccountProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全信息。
|
||||
/// </summary>
|
||||
public PersonalSecuritySnapshotDto? SecuritySnapshot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色权限概览。
|
||||
/// </summary>
|
||||
public PersonalRolePermissionSummaryDto? RoleSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 归属信息。
|
||||
/// </summary>
|
||||
public PersonalTenantAffiliationDto? TenantAffiliation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额摘要(可选)。
|
||||
/// </summary>
|
||||
public PersonalQuotaUsageSummaryDto? QuotaSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 子模块执行状态。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PersonalModuleStatusDto> ModuleStatuses { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心通用分页查询契约。
|
||||
/// </summary>
|
||||
public sealed record PersonalPagedQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码,从 1 开始。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 查询开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 查询结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取有效页码。
|
||||
/// </summary>
|
||||
/// <returns>最小为 1 的页码。</returns>
|
||||
public int ResolvePage()
|
||||
{
|
||||
// 1. 对非法页码做兜底
|
||||
return Page < 1 ? 1 : Page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取有效页大小。
|
||||
/// </summary>
|
||||
/// <param name="defaultPageSize">默认页大小。</param>
|
||||
/// <param name="maxPageSize">最大页大小。</param>
|
||||
/// <returns>落在约束范围内的页大小。</returns>
|
||||
public int ResolvePageSize(int defaultPageSize = 20, int maxPageSize = 50)
|
||||
{
|
||||
// 1. 兜底默认值
|
||||
var resolvedDefault = defaultPageSize <= 0 ? 20 : defaultPageSize;
|
||||
var resolvedMax = maxPageSize <= 0 ? 50 : maxPageSize;
|
||||
|
||||
// 2. 对非法页大小做兜底
|
||||
if (PageSize <= 0)
|
||||
{
|
||||
return resolvedDefault;
|
||||
}
|
||||
|
||||
// 3. 对超限页大小做截断
|
||||
return PageSize > resolvedMax ? resolvedMax : PageSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心支付记录 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalPaymentRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 支付记录 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long PaymentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关联账单 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付金额。
|
||||
/// </summary>
|
||||
public decimal PaidAmount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
public string PaymentMethod { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付状态。
|
||||
/// </summary>
|
||||
public string PaymentStatus { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? PaidAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心配额项 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalQuotaUsageItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额编码。
|
||||
/// </summary>
|
||||
public string QuotaCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额名称。
|
||||
/// </summary>
|
||||
public string QuotaName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 配额上限值。
|
||||
/// </summary>
|
||||
public decimal LimitValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已用值。
|
||||
/// </summary>
|
||||
public decimal UsedValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 单位。
|
||||
/// </summary>
|
||||
public string Unit { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用比例。
|
||||
/// </summary>
|
||||
public decimal UsageRatio { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心配额摘要 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalQuotaUsageSummaryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额项集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PersonalQuotaUsageItemDto> Items { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心角色项 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalRoleItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 角色编码。
|
||||
/// </summary>
|
||||
public string RoleCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 角色名称。
|
||||
/// </summary>
|
||||
public string RoleName { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心角色与权限概览 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalRolePermissionSummaryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<PersonalRoleItemDto> Roles { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 权限数量。
|
||||
/// </summary>
|
||||
public int PermissionCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心安全状态快照 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalSecuritySnapshotDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 最近登录时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败登录次数。
|
||||
/// </summary>
|
||||
public int FailedLoginCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已锁定。
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 锁定截止时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LockedUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否强制修改密码。
|
||||
/// </summary>
|
||||
public bool IsForceChangePassword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心租户归属信息 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalTenantAffiliationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public string TenantName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 商户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户名称。
|
||||
/// </summary>
|
||||
public string? MerchantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户状态。
|
||||
/// </summary>
|
||||
public string MerchantStatus { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 当前套餐名称。
|
||||
/// </summary>
|
||||
public string? PackageName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? SubscriptionExpireAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心账单/配额可见角色配置 DTO。
|
||||
/// </summary>
|
||||
public sealed class PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 配额可见角色编码集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> QuotaVisibleRoleCodes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 账单可见角色编码集合。
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> BillingVisibleRoleCodes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 最近更新人(雪花算法,序列化为字符串)。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long UpdatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心总览处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalOverviewQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalMaskingService personalMaskingService,
|
||||
PersonalModuleStatusService moduleStatusService,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantPackageRepository tenantPackageRepository,
|
||||
IMerchantRepository merchantRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<GetPersonalOverviewQuery, PersonalOverviewDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>总览结果。</returns>
|
||||
public async Task<PersonalOverviewDto> Handle(GetPersonalOverviewQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取必需上下文
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var traceId = context.TraceId;
|
||||
|
||||
// 2. 初始化总览容器
|
||||
var moduleStatuses = new List<PersonalModuleStatusDto>();
|
||||
PersonalAccountProfileDto? accountProfile = null;
|
||||
PersonalSecuritySnapshotDto? securitySnapshot = null;
|
||||
PersonalRolePermissionSummaryDto? roleSummary = null;
|
||||
PersonalTenantAffiliationDto? tenantAffiliation = null;
|
||||
|
||||
// 3. 加载账号与安全模块
|
||||
var user = await identityUserRepository.FindByIdAsync(context.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
if (user.TenantId != context.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户用户数据");
|
||||
}
|
||||
|
||||
accountProfile = new PersonalAccountProfileDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
AvatarUrl = user.Avatar,
|
||||
PhoneMasked = personalMaskingService.MaskPhone(user.Phone),
|
||||
EmailMasked = personalMaskingService.MaskEmail(user.Email),
|
||||
RegisteredAt = user.CreatedAt
|
||||
};
|
||||
securitySnapshot = new PersonalSecuritySnapshotDto
|
||||
{
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
FailedLoginCount = user.FailedLoginCount,
|
||||
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil > DateTime.UtcNow),
|
||||
LockedUntil = user.LockedUntil,
|
||||
IsForceChangePassword = user.MustChangePassword
|
||||
};
|
||||
moduleStatuses.Add(moduleStatusService.BuildOk("accountSecurity", traceId));
|
||||
|
||||
// 4. 加载角色权限模块(失败可降级)
|
||||
try
|
||||
{
|
||||
roleSummary = await mediator.Send(new GetPersonalRolesQuery(), cancellationToken);
|
||||
moduleStatuses.Add(moduleStatusService.BuildOk("roleSummary", traceId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
moduleStatuses.Add(moduleStatusService.BuildIssue("roleSummary", "degraded", ErrorCodes.InternalServerError.ToString(), ex.Message, traceId));
|
||||
}
|
||||
|
||||
// 5. 加载租户归属模块(失败可降级)
|
||||
try
|
||||
{
|
||||
var tenant = await tenantRepository.FindByIdAsync(context.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
var subscription = await tenantRepository.GetActiveSubscriptionAsync(context.TenantId, cancellationToken);
|
||||
|
||||
string? packageName = null;
|
||||
if (subscription is not null)
|
||||
{
|
||||
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
|
||||
packageName = package?.Name;
|
||||
}
|
||||
|
||||
string? merchantName = null;
|
||||
string merchantStatus = "unknown";
|
||||
if (user.MerchantId is > 0)
|
||||
{
|
||||
var merchant = await merchantRepository.FindByIdAsync(user.MerchantId.Value, context.TenantId, cancellationToken);
|
||||
merchantName = merchant?.BrandName;
|
||||
merchantStatus = merchant?.Status.ToString().ToLowerInvariant() ?? "unknown";
|
||||
}
|
||||
|
||||
tenantAffiliation = new PersonalTenantAffiliationDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
TenantName = tenant.Name,
|
||||
MerchantId = user.MerchantId,
|
||||
MerchantName = merchantName,
|
||||
MerchantStatus = merchantStatus,
|
||||
PackageName = packageName,
|
||||
SubscriptionExpireAt = subscription?.EffectiveTo ?? tenant.EffectiveTo
|
||||
};
|
||||
moduleStatuses.Add(moduleStatusService.BuildOk("tenantAffiliation", traceId));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
moduleStatuses.Add(moduleStatusService.BuildIssue("tenantAffiliation", "degraded", ErrorCodes.InternalServerError.ToString(), ex.Message, traceId));
|
||||
}
|
||||
|
||||
// 6. 计算总体状态并返回
|
||||
var hasAnyData = accountProfile is not null
|
||||
|| securitySnapshot is not null
|
||||
|| roleSummary is not null
|
||||
|| tenantAffiliation is not null;
|
||||
var requestId = string.IsNullOrWhiteSpace(traceId) ? Guid.NewGuid().ToString("N") : traceId;
|
||||
var overallStatus = moduleStatusService.ResolveOverallStatus(moduleStatuses, hasAnyData);
|
||||
return new PersonalOverviewDto
|
||||
{
|
||||
RequestId = requestId,
|
||||
OverallStatus = overallStatus,
|
||||
AccountProfile = accountProfile,
|
||||
SecuritySnapshot = securitySnapshot,
|
||||
RoleSummary = roleSummary,
|
||||
TenantAffiliation = tenantAffiliation,
|
||||
QuotaSummary = null,
|
||||
ModuleStatuses = moduleStatuses
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心配额摘要处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalQuotaQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalAuditService personalAuditService,
|
||||
ITenantQuotaUsageRepository tenantQuotaUsageRepository,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<GetPersonalQuotaQuery, PersonalQuotaUsageSummaryDto>
|
||||
{
|
||||
private static readonly string[] DefaultQuotaVisibleRoles = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额摘要。</returns>
|
||||
public async Task<PersonalQuotaUsageSummaryDto> Handle(GetPersonalQuotaQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 获取请求上下文
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
|
||||
// 2. 校验可见角色权限
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
var allowedRoles = rule?.QuotaVisibleRoleCodes?.Length > 0
|
||||
? rule.QuotaVisibleRoleCodes
|
||||
: DefaultQuotaVisibleRoles;
|
||||
if (!HasAnyRole(context.RoleCodes, allowedRoles))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问配额数据");
|
||||
}
|
||||
|
||||
// 3. 查询配额并组装摘要
|
||||
var usages = await tenantQuotaUsageRepository.GetByTenantAsync(context.TenantId, cancellationToken);
|
||||
var items = usages
|
||||
.OrderBy(x => x.QuotaType)
|
||||
.Select(MapQuotaItem)
|
||||
.ToList();
|
||||
|
||||
// 4. 记录审计并返回
|
||||
await personalAuditService.RecordSensitiveQueryAsync("quota", true, "查询配额摘要成功", cancellationToken);
|
||||
return new PersonalQuotaUsageSummaryDto
|
||||
{
|
||||
Items = items
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. 记录失败审计并继续抛出
|
||||
await personalAuditService.RecordSensitiveQueryAsync("quota", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PersonalQuotaUsageItemDto MapQuotaItem(TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsage usage)
|
||||
{
|
||||
// 1. 映射元数据
|
||||
var metadata = usage.QuotaType switch
|
||||
{
|
||||
TenantQuotaType.StoreCount => ("store_count", "门店数量", "个"),
|
||||
TenantQuotaType.AccountCount => ("account_count", "账号数量", "个"),
|
||||
TenantQuotaType.Storage => ("storage", "存储空间", "GB"),
|
||||
TenantQuotaType.SmsCredits => ("sms_credits", "短信额度", "条"),
|
||||
TenantQuotaType.DeliveryOrders => ("delivery_orders", "配送订单", "单"),
|
||||
TenantQuotaType.PromotionSlots => ("promotion_slots", "营销位", "个"),
|
||||
_ => (usage.QuotaType.ToString().ToLowerInvariant(), usage.QuotaType.ToString(), string.Empty)
|
||||
};
|
||||
|
||||
// 2. 计算使用率
|
||||
var ratio = usage.LimitValue <= 0 ? 0 : Math.Round(usage.UsedValue / usage.LimitValue, 4);
|
||||
|
||||
// 3. 返回 DTO
|
||||
return new PersonalQuotaUsageItemDto
|
||||
{
|
||||
QuotaCode = metadata.Item1,
|
||||
QuotaName = metadata.Item2,
|
||||
LimitValue = usage.LimitValue,
|
||||
UsedValue = usage.UsedValue,
|
||||
Unit = metadata.Item3,
|
||||
UsageRatio = ratio
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 构建角色集合并执行交集判断
|
||||
var current = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 返回是否命中任一允许角色
|
||||
return allowedRoles.Any(current.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人角色与权限概览处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalRolesQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IRolePermissionRepository rolePermissionRepository)
|
||||
: IRequestHandler<GetPersonalRolesQuery, PersonalRolePermissionSummaryDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限概览。</returns>
|
||||
public async Task<PersonalRolePermissionSummaryDto> Handle(GetPersonalRolesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取请求上下文
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
|
||||
// 2. 获取用户角色关系
|
||||
var userRoles = await userRoleRepository.GetByUserIdAsync(context.TenantId, context.UserId, cancellationToken);
|
||||
var roleIds = userRoles.Select(static x => x.RoleId).Distinct().ToArray();
|
||||
if (roleIds.Length == 0)
|
||||
{
|
||||
return new PersonalRolePermissionSummaryDto
|
||||
{
|
||||
Roles = [],
|
||||
PermissionCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 获取角色明细
|
||||
var roles = await roleRepository.GetByIdsAsync(context.TenantId, roleIds, cancellationToken);
|
||||
var roleItems = roles
|
||||
.OrderBy(static x => x.Name)
|
||||
.Select(static x => new PersonalRoleItemDto
|
||||
{
|
||||
RoleCode = x.Code,
|
||||
RoleName = x.Name
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 4. 统计去重权限数量
|
||||
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(context.TenantId, roleIds, cancellationToken);
|
||||
var permissionCount = rolePermissions
|
||||
.Select(static x => x.PermissionId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
// 5. 返回结果
|
||||
return new PersonalRolePermissionSummaryDto
|
||||
{
|
||||
Roles = roleItems,
|
||||
PermissionCount = permissionCount
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心可见角色配置处理器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalVisibilityRoleConfigQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<GetPersonalVisibilityRoleConfigQuery, PersonalVisibilityRoleConfigDto>
|
||||
{
|
||||
private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"];
|
||||
private static readonly string[] DefaultVisibleRoleCodes = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>可见角色配置。</returns>
|
||||
public async Task<PersonalVisibilityRoleConfigDto> Handle(GetPersonalVisibilityRoleConfigQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并校验管理权限
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
if (!HasAnyRole(context.RoleCodes, ManagerRoleCodes))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权查看可见角色配置");
|
||||
}
|
||||
|
||||
// 2. 查询并返回规则
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
if (rule is null)
|
||||
{
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
QuotaVisibleRoleCodes = DefaultVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = DefaultVisibleRoleCodes,
|
||||
UpdatedBy = context.UserId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = rule.TenantId,
|
||||
QuotaVisibleRoleCodes = rule.QuotaVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = rule.BillingVisibleRoleCodes,
|
||||
UpdatedBy = rule.UpdatedBy ?? context.UserId,
|
||||
UpdatedAt = rule.UpdatedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 归一化当前角色
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 判断是否命中允许角色
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人中心账单记录处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalBillingStatementsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalDateRangeValidator personalDateRangeValidator,
|
||||
PersonalAuditService personalAuditService,
|
||||
ITenantBillingRepository tenantBillingRepository,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<SearchPersonalBillingStatementsQuery, PagedResult<PersonalBillingStatementDto>>
|
||||
{
|
||||
private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>账单分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalBillingStatementDto>> Handle(SearchPersonalBillingStatementsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 获取上下文并校验可见角色
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
var allowedRoles = rule?.BillingVisibleRoleCodes?.Length > 0
|
||||
? rule.BillingVisibleRoleCodes
|
||||
: DefaultBillingVisibleRoles;
|
||||
if (!HasAnyRole(context.RoleCodes, allowedRoles))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问账单数据");
|
||||
}
|
||||
|
||||
// 2. 解析时间窗与分页参数
|
||||
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 3. 查询并分页
|
||||
var statements = await tenantBillingRepository.SearchAsync(context.TenantId, null, dateRange.From, dateRange.To, cancellationToken);
|
||||
var ordered = statements.OrderByDescending(static x => x.PeriodEnd).ToList();
|
||||
var items = ordered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(MapBilling)
|
||||
.ToList();
|
||||
|
||||
// 4. 记录审计并返回
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-statements", true, "查询账单成功", cancellationToken);
|
||||
return new PagedResult<PersonalBillingStatementDto>(items, page, pageSize, ordered.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. 记录失败审计并继续抛出
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-statements", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PersonalBillingStatementDto MapBilling(TenantBillingStatement billing)
|
||||
{
|
||||
// 1. 映射账单状态
|
||||
var status = billing.Status switch
|
||||
{
|
||||
TenantBillingStatus.Pending when billing.AmountPaid > 0 => "partial_paid",
|
||||
_ => billing.Status.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
// 2. 返回 DTO
|
||||
return new PersonalBillingStatementDto
|
||||
{
|
||||
StatementId = billing.Id,
|
||||
BillingPeriodStart = DateOnly.FromDateTime(billing.PeriodStart),
|
||||
BillingPeriodEnd = DateOnly.FromDateTime(billing.PeriodEnd),
|
||||
AmountDue = billing.AmountDue,
|
||||
AmountPaid = billing.AmountPaid,
|
||||
Status = status,
|
||||
DueAt = billing.DueDate
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 归一化当前角色
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 命中任一允许角色即通过
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人消息摘要处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalNotificationsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
ITenantNotificationRepository tenantNotificationRepository)
|
||||
: IRequestHandler<SearchPersonalNotificationsQuery, PagedResult<PersonalNotificationDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>消息摘要分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalNotificationDto>> Handle(SearchPersonalNotificationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并解析分页
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 2. 查询通知
|
||||
var notifications = await tenantNotificationRepository.SearchAsync(
|
||||
context.TenantId,
|
||||
severity: null,
|
||||
unreadOnly: request.UnreadOnly,
|
||||
from: null,
|
||||
to: null,
|
||||
cancellationToken);
|
||||
|
||||
// 3. 排序分页并映射
|
||||
var ordered = notifications.OrderByDescending(static x => x.SentAt).ToList();
|
||||
var items = ordered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(static x => new PersonalNotificationDto
|
||||
{
|
||||
NotificationId = x.Id,
|
||||
Title = x.Title,
|
||||
Category = x.Severity.ToString().ToLowerInvariant(),
|
||||
IsRead = x.ReadAt.HasValue,
|
||||
SentAt = x.SentAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// 4. 返回结果
|
||||
return new PagedResult<PersonalNotificationDto>(items, page, pageSize, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人操作记录处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalOperationsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalDateRangeValidator personalDateRangeValidator,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<SearchPersonalOperationsQuery, PagedResult<PersonalOperationLogDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>操作记录分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalOperationLogDto>> Handle(SearchPersonalOperationsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并解析时间窗
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
|
||||
|
||||
// 2. 解析分页参数(默认 50,上限 50)
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 50 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 3. 查询并映射
|
||||
var paged = await operationLogRepository.SearchByOperatorPagedAsync(
|
||||
context.TenantId,
|
||||
context.UserId.ToString(),
|
||||
dateRange.From,
|
||||
dateRange.To,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
var items = paged.Items.Select(log => new PersonalOperationLogDto
|
||||
{
|
||||
OperationId = log.Id,
|
||||
OperatorUserId = ParseOperatorUserId(log.OperatorId, context.UserId),
|
||||
ActionType = log.OperationType,
|
||||
TargetType = log.TargetType,
|
||||
TargetId = ResolveTargetId(log.TargetIds),
|
||||
IsSuccess = log.Success,
|
||||
OccurredAt = log.CreatedAt
|
||||
}).ToList();
|
||||
|
||||
// 4. 返回分页结果
|
||||
return new PagedResult<PersonalOperationLogDto>(items, page, pageSize, paged.Total);
|
||||
}
|
||||
|
||||
private static long ParseOperatorUserId(string? operatorId, long fallbackUserId)
|
||||
{
|
||||
// 1. 尝试从字符串解析用户 ID
|
||||
if (long.TryParse(operatorId, out var parsed) && parsed > 0)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// 2. 解析失败时回退当前用户
|
||||
return fallbackUserId;
|
||||
}
|
||||
|
||||
private static string? ResolveTargetId(string? targetIds)
|
||||
{
|
||||
// 1. 空值直接返回
|
||||
if (string.IsNullOrWhiteSpace(targetIds))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 尝试解析 JSON 数组并提取首个元素
|
||||
var normalized = targetIds.Trim();
|
||||
if (normalized.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(normalized);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
return doc.RootElement[0].ToString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 3. JSON 解析失败时按原始字符串返回
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 非数组场景直接返回原值
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Application.App.Personal.Validators;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人中心支付记录处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchPersonalPaymentsQueryHandler(
|
||||
PersonalContextService personalContextService,
|
||||
PersonalDateRangeValidator personalDateRangeValidator,
|
||||
PersonalAuditService personalAuditService,
|
||||
ITenantPaymentRepository tenantPaymentRepository,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<SearchPersonalPaymentsQuery, PagedResult<PersonalPaymentRecordDto>>
|
||||
{
|
||||
private static readonly string[] DefaultBillingVisibleRoles = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理查询。
|
||||
/// </summary>
|
||||
/// <param name="request">查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>支付记录分页结果。</returns>
|
||||
public async Task<PagedResult<PersonalPaymentRecordDto>> Handle(SearchPersonalPaymentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. 获取上下文并校验角色可见性
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
var rule = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
var allowedRoles = rule?.BillingVisibleRoleCodes?.Length > 0
|
||||
? rule.BillingVisibleRoleCodes
|
||||
: DefaultBillingVisibleRoles;
|
||||
if (!HasAnyRole(context.RoleCodes, allowedRoles))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权访问支付数据");
|
||||
}
|
||||
|
||||
// 2. 解析时间窗和分页参数
|
||||
var dateRange = personalDateRangeValidator.Resolve(request.From, request.To);
|
||||
var page = request.Page <= 0 ? 1 : request.Page;
|
||||
var pageSize = request.PageSize <= 0 ? 20 : Math.Min(request.PageSize, 50);
|
||||
|
||||
// 3. 查询分页数据
|
||||
var paged = await tenantPaymentRepository.SearchPagedAsync(
|
||||
context.TenantId,
|
||||
dateRange.From,
|
||||
dateRange.To,
|
||||
page,
|
||||
pageSize,
|
||||
cancellationToken);
|
||||
var items = paged.Items.Select(MapPayment).ToList();
|
||||
|
||||
// 4. 记录审计并返回
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-payments", true, "查询支付记录成功", cancellationToken);
|
||||
return new PagedResult<PersonalPaymentRecordDto>(items, page, pageSize, paged.Total);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. 记录失败审计并继续抛出
|
||||
await personalAuditService.RecordSensitiveQueryAsync("billing-payments", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static PersonalPaymentRecordDto MapPayment(Domain.Tenants.Entities.TenantPayment payment)
|
||||
{
|
||||
// 1. 映射支付方式与状态
|
||||
var method = payment.Method switch
|
||||
{
|
||||
TenantPaymentMethod.Online => "online",
|
||||
TenantPaymentMethod.BankTransfer => "bank_transfer",
|
||||
TenantPaymentMethod.Other => "other",
|
||||
_ => payment.Method.ToString().ToLowerInvariant()
|
||||
};
|
||||
var status = payment.Status switch
|
||||
{
|
||||
TenantPaymentStatus.Pending => "pending",
|
||||
TenantPaymentStatus.Success => "success",
|
||||
TenantPaymentStatus.Failed => "failed",
|
||||
TenantPaymentStatus.Refunded => "refunded",
|
||||
_ => payment.Status.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
// 2. 返回 DTO
|
||||
return new PersonalPaymentRecordDto
|
||||
{
|
||||
PaymentId = payment.Id,
|
||||
StatementId = payment.BillingStatementId,
|
||||
PaidAmount = payment.Amount,
|
||||
PaymentMethod = method,
|
||||
PaymentStatus = status,
|
||||
PaidAt = payment.PaidAt
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 构建当前角色集合
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 判断是否命中允许角色
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
using TakeoutSaaS.Application.App.Personal.Services;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新个人中心可见角色配置处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdatePersonalVisibilityRoleConfigCommandHandler(
|
||||
PersonalContextService personalContextService,
|
||||
ITenantVisibilityRoleRuleRepository visibilityRoleRuleRepository)
|
||||
: IRequestHandler<UpdatePersonalVisibilityRoleConfigCommand, PersonalVisibilityRoleConfigDto>
|
||||
{
|
||||
private static readonly string[] ManagerRoleCodes = ["tenant-owner", "tenant-admin"];
|
||||
|
||||
/// <summary>
|
||||
/// 处理命令。
|
||||
/// </summary>
|
||||
/// <param name="request">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的配置。</returns>
|
||||
public async Task<PersonalVisibilityRoleConfigDto> Handle(UpdatePersonalVisibilityRoleConfigCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取上下文并校验管理权限
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
if (!HasAnyRole(context.RoleCodes, ManagerRoleCodes))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "当前角色无权更新可见角色配置");
|
||||
}
|
||||
|
||||
// 2. 规范化角色编码集合
|
||||
var normalizedQuotaRoles = NormalizeRoleCodes(request.QuotaVisibleRoleCodes);
|
||||
var normalizedBillingRoles = NormalizeRoleCodes(request.BillingVisibleRoleCodes);
|
||||
|
||||
// 3. 查询并落库
|
||||
var existing = await visibilityRoleRuleRepository.FindByTenantIdAsync(context.TenantId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
var created = new TenantVisibilityRoleRule
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
QuotaVisibleRoleCodes = normalizedQuotaRoles,
|
||||
BillingVisibleRoleCodes = normalizedBillingRoles,
|
||||
UpdatedBy = context.UserId,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
await visibilityRoleRuleRepository.AddAsync(created, cancellationToken);
|
||||
await visibilityRoleRuleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = created.TenantId,
|
||||
QuotaVisibleRoleCodes = created.QuotaVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = created.BillingVisibleRoleCodes,
|
||||
UpdatedBy = created.UpdatedBy ?? context.UserId,
|
||||
UpdatedAt = created.UpdatedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
existing.QuotaVisibleRoleCodes = normalizedQuotaRoles;
|
||||
existing.BillingVisibleRoleCodes = normalizedBillingRoles;
|
||||
existing.UpdatedBy = context.UserId;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await visibilityRoleRuleRepository.UpdateAsync(existing, cancellationToken);
|
||||
await visibilityRoleRuleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 4. 返回更新结果
|
||||
return new PersonalVisibilityRoleConfigDto
|
||||
{
|
||||
TenantId = existing.TenantId,
|
||||
QuotaVisibleRoleCodes = existing.QuotaVisibleRoleCodes,
|
||||
BillingVisibleRoleCodes = existing.BillingVisibleRoleCodes,
|
||||
UpdatedBy = existing.UpdatedBy ?? context.UserId,
|
||||
UpdatedAt = existing.UpdatedAt ?? DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string[] NormalizeRoleCodes(IEnumerable<string> roleCodes)
|
||||
{
|
||||
// 1. 过滤空值并去重
|
||||
return roleCodes
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool HasAnyRole(IReadOnlyList<string> currentRoles, IReadOnlyList<string> allowedRoles)
|
||||
{
|
||||
// 1. 构建当前角色集合
|
||||
var roleSet = currentRoles
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(static x => x.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 2. 判断是否命中允许角色
|
||||
return allowedRoles.Any(roleSet.Contains);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心映射辅助。
|
||||
/// </summary>
|
||||
public static class PersonalMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 映射分页结果。
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">源类型。</typeparam>
|
||||
/// <typeparam name="TDestination">目标类型。</typeparam>
|
||||
/// <param name="source">源分页结果。</param>
|
||||
/// <param name="mapper">单项映射函数。</param>
|
||||
/// <returns>目标分页结果。</returns>
|
||||
public static PagedResult<TDestination> ToPagedResult<TSource, TDestination>(
|
||||
this PagedResult<TSource> source,
|
||||
Func<TSource, TDestination> mapper)
|
||||
{
|
||||
// 1. 映射分页项集合
|
||||
var items = source.Items.Select(mapper).ToList();
|
||||
|
||||
// 2. 返回目标分页对象
|
||||
return new PagedResult<TDestination>(items, source.Page, source.PageSize, source.TotalCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 便捷构建模块状态 DTO。
|
||||
/// </summary>
|
||||
/// <param name="module">模块名称。</param>
|
||||
/// <param name="status">模块状态。</param>
|
||||
/// <param name="errorCode">错误码。</param>
|
||||
/// <param name="errorMessage">错误信息。</param>
|
||||
/// <param name="traceId">追踪标识。</param>
|
||||
/// <returns>模块状态 DTO。</returns>
|
||||
public static PersonalModuleStatusDto ToModuleStatus(
|
||||
this string module,
|
||||
string status,
|
||||
string? errorCode = null,
|
||||
string? errorMessage = null,
|
||||
string? traceId = null)
|
||||
{
|
||||
// 1. 构建通用模块状态
|
||||
return new PersonalModuleStatusDto
|
||||
{
|
||||
Module = module,
|
||||
Status = status,
|
||||
ErrorCode = errorCode,
|
||||
ErrorMessage = errorMessage,
|
||||
TraceId = traceId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心总览查询。
|
||||
/// </summary>
|
||||
public sealed record GetPersonalOverviewQuery : IRequest<PersonalOverviewDto>;
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心配额摘要查询。
|
||||
/// </summary>
|
||||
public sealed record GetPersonalQuotaQuery : IRequest<PersonalQuotaUsageSummaryDto>;
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人角色权限概览查询。
|
||||
/// </summary>
|
||||
public sealed record GetPersonalRolesQuery : IRequest<PersonalRolePermissionSummaryDto>;
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取个人中心可见角色配置查询。
|
||||
/// </summary>
|
||||
public sealed record GetPersonalVisibilityRoleConfigQuery : IRequest<PersonalVisibilityRoleConfigDto>;
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人中心账单记录。
|
||||
/// </summary>
|
||||
public sealed record SearchPersonalBillingStatementsQuery : IRequest<PagedResult<PersonalBillingStatementDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? To { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人消息摘要。
|
||||
/// </summary>
|
||||
public sealed record SearchPersonalNotificationsQuery : IRequest<PagedResult<PersonalNotificationDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅返回未读。
|
||||
/// </summary>
|
||||
public bool UnreadOnly { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人操作记录。
|
||||
/// </summary>
|
||||
public sealed record SearchPersonalOperationsQuery : IRequest<PagedResult<PersonalOperationLogDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数(默认 50,上限 50)。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? To { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询个人中心支付记录。
|
||||
/// </summary>
|
||||
public sealed record SearchPersonalPaymentsQuery : IRequest<PagedResult<PersonalPaymentRecordDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? To { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 更新个人中心可见角色配置命令。
|
||||
/// </summary>
|
||||
public sealed record UpdatePersonalVisibilityRoleConfigCommand : IRequest<PersonalVisibilityRoleConfigDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额可见角色编码集合。
|
||||
/// </summary>
|
||||
public string[] QuotaVisibleRoleCodes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 账单可见角色编码集合。
|
||||
/// </summary>
|
||||
public string[] BillingVisibleRoleCodes { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心敏感查询审计服务。
|
||||
/// </summary>
|
||||
public sealed class PersonalAuditService(
|
||||
PersonalContextService personalContextService,
|
||||
ILogger<PersonalAuditService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录敏感查询审计日志。
|
||||
/// </summary>
|
||||
/// <param name="module">模块名称。</param>
|
||||
/// <param name="isSuccess">是否成功。</param>
|
||||
/// <param name="detail">审计说明。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RecordSensitiveQueryAsync(string module, bool isSuccess, string? detail, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 获取当前上下文
|
||||
var context = personalContextService.GetRequiredContext();
|
||||
|
||||
// 2. 写入结构化审计日志
|
||||
logger.LogInformation(
|
||||
"Personal sensitive query audited. Module={Module}, TenantId={TenantId}, UserId={UserId}, Success={Success}, TraceId={TraceId}, Detail={Detail}",
|
||||
module,
|
||||
context.TenantId,
|
||||
context.UserId,
|
||||
isSuccess,
|
||||
context.TraceId,
|
||||
detail);
|
||||
|
||||
// 3. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Claims;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心请求上下文服务。
|
||||
/// </summary>
|
||||
public sealed class PersonalContextService(
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ITenantProvider tenantProvider,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取当前请求必需上下文。
|
||||
/// </summary>
|
||||
/// <returns>用户 ID、租户 ID、角色编码集合、追踪标识。</returns>
|
||||
public (long UserId, long TenantId, IReadOnlyList<string> RoleCodes, string TraceId) GetRequiredContext()
|
||||
{
|
||||
// 1. 读取用户上下文
|
||||
var userId = currentUserAccessor.UserId;
|
||||
if (userId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或登录已过期");
|
||||
}
|
||||
|
||||
// 2. 读取租户上下文
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
|
||||
}
|
||||
|
||||
// 3. 读取角色与追踪标识
|
||||
var roleCodes = ResolveRoleCodes();
|
||||
var traceId = ResolveTraceId();
|
||||
return (userId, tenantId, roleCodes, traceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前请求 TraceId。
|
||||
/// </summary>
|
||||
/// <returns>TraceId,若不存在则返回空字符串。</returns>
|
||||
public string ResolveTraceId()
|
||||
{
|
||||
// 1. 从 Activity 与 HttpContext 中解析 TraceId
|
||||
var traceId = System.Diagnostics.Activity.Current?.TraceId.ToString()
|
||||
?? httpContextAccessor.HttpContext?.TraceIdentifier
|
||||
?? string.Empty;
|
||||
|
||||
// 2. 返回追踪标识
|
||||
return traceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前用户角色编码。
|
||||
/// </summary>
|
||||
/// <returns>角色编码集合。</returns>
|
||||
public IReadOnlyList<string> ResolveRoleCodes()
|
||||
{
|
||||
// 1. 读取 Claim 角色值
|
||||
var claims = httpContextAccessor.HttpContext?.User?.Claims ?? [];
|
||||
var roles = claims
|
||||
.Where(static c => c.Type == ClaimTypes.Role || c.Type == "role")
|
||||
.Select(static c => c.Value)
|
||||
.Where(static x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 2. 返回去重后的角色编码
|
||||
return roles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace TakeoutSaaS.Application.App.Personal.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心敏感信息脱敏服务。
|
||||
/// </summary>
|
||||
public sealed class PersonalMaskingService
|
||||
{
|
||||
/// <summary>
|
||||
/// 手机号脱敏。
|
||||
/// </summary>
|
||||
/// <param name="phone">原始手机号。</param>
|
||||
/// <returns>脱敏手机号。</returns>
|
||||
public string? MaskPhone(string? phone)
|
||||
{
|
||||
// 1. 空值直接返回
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 长度不足时按固定策略脱敏
|
||||
if (phone.Length < 7)
|
||||
{
|
||||
return $"{phone[..1]}***";
|
||||
}
|
||||
|
||||
// 3. 标准手机号保留前三后四
|
||||
return $"{phone[..3]}****{phone[^4..]}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱脱敏。
|
||||
/// </summary>
|
||||
/// <param name="email">原始邮箱。</param>
|
||||
/// <returns>脱敏邮箱。</returns>
|
||||
public string? MaskEmail(string? email)
|
||||
{
|
||||
// 1. 空值直接返回
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 非法格式按通用策略脱敏
|
||||
var atIndex = email.IndexOf('@');
|
||||
if (atIndex <= 0 || atIndex == email.Length - 1)
|
||||
{
|
||||
return "***";
|
||||
}
|
||||
|
||||
// 3. 合法邮箱保留首尾字符和域名
|
||||
var localPart = email[..atIndex];
|
||||
var domainPart = email[atIndex..];
|
||||
if (localPart.Length == 1)
|
||||
{
|
||||
return $"{localPart[0]}***{domainPart}";
|
||||
}
|
||||
|
||||
return $"{localPart[0]}***{localPart[^1]}{domainPart}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using TakeoutSaaS.Application.App.Personal.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心模块状态构建服务。
|
||||
/// </summary>
|
||||
public sealed class PersonalModuleStatusService
|
||||
{
|
||||
/// <summary>
|
||||
/// 构建成功状态。
|
||||
/// </summary>
|
||||
/// <param name="module">模块名称。</param>
|
||||
/// <param name="traceId">追踪标识。</param>
|
||||
/// <returns>模块状态 DTO。</returns>
|
||||
public PersonalModuleStatusDto BuildOk(string module, string? traceId = null)
|
||||
{
|
||||
// 1. 生成成功状态
|
||||
return new PersonalModuleStatusDto
|
||||
{
|
||||
Module = module,
|
||||
Status = "ok",
|
||||
TraceId = traceId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建失败或降级状态。
|
||||
/// </summary>
|
||||
/// <param name="module">模块名称。</param>
|
||||
/// <param name="status">状态值:degraded/failed/timeout/skipped。</param>
|
||||
/// <param name="errorCode">错误码。</param>
|
||||
/// <param name="errorMessage">错误说明。</param>
|
||||
/// <param name="traceId">追踪标识。</param>
|
||||
/// <returns>模块状态 DTO。</returns>
|
||||
public PersonalModuleStatusDto BuildIssue(
|
||||
string module,
|
||||
string status,
|
||||
string? errorCode,
|
||||
string? errorMessage,
|
||||
string? traceId = null)
|
||||
{
|
||||
// 1. 规范状态值
|
||||
var normalizedStatus = NormalizeStatus(status);
|
||||
|
||||
// 2. 生成异常状态
|
||||
return new PersonalModuleStatusDto
|
||||
{
|
||||
Module = module,
|
||||
Status = normalizedStatus,
|
||||
ErrorCode = errorCode,
|
||||
ErrorMessage = errorMessage,
|
||||
TraceId = traceId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算总览整体状态。
|
||||
/// </summary>
|
||||
/// <param name="moduleStatuses">模块状态集合。</param>
|
||||
/// <param name="hasAnyData">是否存在可用数据。</param>
|
||||
/// <returns>overallStatus 值。</returns>
|
||||
public string ResolveOverallStatus(IReadOnlyCollection<PersonalModuleStatusDto> moduleStatuses, bool hasAnyData)
|
||||
{
|
||||
// 1. 无模块时按是否有数据判定
|
||||
if (moduleStatuses.Count == 0)
|
||||
{
|
||||
return hasAnyData ? "success" : "failure";
|
||||
}
|
||||
|
||||
// 2. 全部成功直接返回 success
|
||||
var hasIssue = moduleStatuses.Any(static x => !string.Equals(x.Status, "ok", StringComparison.OrdinalIgnoreCase));
|
||||
if (!hasIssue)
|
||||
{
|
||||
return "success";
|
||||
}
|
||||
|
||||
// 3. 存在降级时按可用数据判定
|
||||
return hasAnyData ? "partial_success" : "failure";
|
||||
}
|
||||
|
||||
private static string NormalizeStatus(string status)
|
||||
{
|
||||
// 1. 空状态统一按 failed 处理
|
||||
if (string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
return "failed";
|
||||
}
|
||||
|
||||
// 2. 限定可用状态值
|
||||
return status.ToLowerInvariant() switch
|
||||
{
|
||||
"ok" => "ok",
|
||||
"degraded" => "degraded",
|
||||
"failed" => "failed",
|
||||
"timeout" => "timeout",
|
||||
"skipped" => "skipped",
|
||||
_ => "failed"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心总览查询校验器。
|
||||
/// </summary>
|
||||
public sealed class GetPersonalOverviewQueryValidator : AbstractValidator<GetPersonalOverviewQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化校验规则。
|
||||
/// </summary>
|
||||
public GetPersonalOverviewQueryValidator()
|
||||
{
|
||||
// 1. 当前查询无显式参数,保留验证器用于后续扩展
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 个人中心通用时间范围校验器。
|
||||
/// </summary>
|
||||
public sealed class PersonalDateRangeValidator
|
||||
{
|
||||
private const int DefaultDays = 90;
|
||||
private const int MaxRangeDays = 365;
|
||||
|
||||
/// <summary>
|
||||
/// 解析并校验时间范围。
|
||||
/// </summary>
|
||||
/// <param name="from">起始时间。</param>
|
||||
/// <param name="to">截止时间。</param>
|
||||
/// <returns>校验后的起止时间(UTC)。</returns>
|
||||
public (DateTime From, DateTime To) Resolve(DateTime? from, DateTime? to)
|
||||
{
|
||||
// 1. 计算默认时间窗
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var resolvedTo = to?.ToUniversalTime() ?? utcNow;
|
||||
var resolvedFrom = from?.ToUniversalTime() ?? resolvedTo.AddDays(-DefaultDays);
|
||||
|
||||
// 2. 校验起止先后关系
|
||||
if (resolvedFrom > resolvedTo)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "时间范围非法:from 不能大于 to");
|
||||
}
|
||||
|
||||
// 3. 校验跨度上限
|
||||
var spanDays = (resolvedTo - resolvedFrom).TotalDays;
|
||||
if (spanDays > MaxRangeDays)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, $"时间范围非法:跨度不能超过 {MaxRangeDays} 天");
|
||||
}
|
||||
|
||||
// 4. 返回校验后的时间范围
|
||||
return (resolvedFrom, resolvedTo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Personal.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Personal.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新个人中心可见角色配置命令校验器。
|
||||
/// </summary>
|
||||
public sealed class UpdatePersonalVisibilityRoleConfigCommandValidator : AbstractValidator<UpdatePersonalVisibilityRoleConfigCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化校验规则。
|
||||
/// </summary>
|
||||
public UpdatePersonalVisibilityRoleConfigCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.QuotaVisibleRoleCodes)
|
||||
.NotEmpty()
|
||||
.Must(HasNoBlankValue)
|
||||
.WithMessage("配额可见角色列表不能为空且不能包含空白角色编码");
|
||||
|
||||
RuleFor(x => x.BillingVisibleRoleCodes)
|
||||
.NotEmpty()
|
||||
.Must(HasNoBlankValue)
|
||||
.WithMessage("账单可见角色列表不能为空且不能包含空白角色编码");
|
||||
}
|
||||
|
||||
private static bool HasNoBlankValue(IEnumerable<string> roleCodes)
|
||||
{
|
||||
// 1. 校验角色编码列表不包含空值
|
||||
return roleCodes.All(static x => !string.IsNullOrWhiteSpace(x));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 租户账单/配额可见角色规则。
|
||||
/// </summary>
|
||||
public sealed class TenantVisibilityRoleRule : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额可见角色编码集合。
|
||||
/// </summary>
|
||||
public string[] QuotaVisibleRoleCodes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 账单可见角色编码集合。
|
||||
/// </summary>
|
||||
public string[] BillingVisibleRoleCodes { get; set; } = [];
|
||||
|
||||
}
|
||||
@@ -7,6 +7,26 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
/// </summary>
|
||||
public interface IOperationLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询指定操作人的操作日志。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="operatorId">操作人标识。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果(数据与总数)。</returns>
|
||||
Task<(IReadOnlyList<OperationLog> Items, int Total)> SearchByOperatorPagedAsync(
|
||||
long tenantId,
|
||||
string operatorId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增操作日志。
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,24 @@ namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
/// </summary>
|
||||
public interface ITenantPaymentRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询支付记录。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="from">开始时间(UTC)。</param>
|
||||
/// <param name="to">结束时间(UTC)。</param>
|
||||
/// <param name="page">页码(从 1 开始)。</param>
|
||||
/// <param name="pageSize">每页条数。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果(数据与总数)。</returns>
|
||||
Task<(IReadOnlyList<TenantPayment> Items, int Total)> SearchPagedAsync(
|
||||
long tenantId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定账单的支付记录列表。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户可见角色规则仓储。
|
||||
/// </summary>
|
||||
public interface ITenantVisibilityRoleRuleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 按租户获取规则。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>规则实体,未配置时返回 null。</returns>
|
||||
Task<TenantVisibilityRoleRule?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增规则。
|
||||
/// </summary>
|
||||
/// <param name="rule">规则实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新规则。
|
||||
/// </summary>
|
||||
/// <param name="rule">规则实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ public static class AppServiceCollectionExtensions
|
||||
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
|
||||
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
|
||||
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
|
||||
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
|
||||
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
|
||||
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
|
||||
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
|
||||
|
||||
@@ -11,6 +11,39 @@ namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
/// </summary>
|
||||
public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<TenantPayment> Items, int Total)> SearchPagedAsync(
|
||||
long tenantId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 归一化分页参数
|
||||
var normalizedPage = page <= 0 ? 1 : page;
|
||||
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
|
||||
|
||||
// 2. 构建查询(按支付时间倒序)
|
||||
var query = context.TenantPayments
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.TenantId == tenantId
|
||||
&& (x.PaidAt ?? x.CreatedAt) >= from
|
||||
&& (x.PaidAt ?? x.CreatedAt) <= to);
|
||||
|
||||
// 3. 执行分页
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PaidAt ?? x.CreatedAt)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 4. 返回分页结果
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户可见角色规则仓储实现。
|
||||
/// </summary>
|
||||
public sealed class TenantVisibilityRoleRuleRepository(TakeoutAppDbContext context) : ITenantVisibilityRoleRuleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 按租户获取规则。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>规则实体或 null。</returns>
|
||||
public Task<TenantVisibilityRoleRule?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVisibilityRoleRules
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增规则。
|
||||
/// </summary>
|
||||
/// <param name="rule">规则实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVisibilityRoleRules.AddAsync(rule, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新规则。
|
||||
/// </summary>
|
||||
/// <param name="rule">规则实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantVisibilityRoleRules.Update(rule);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,10 @@ public sealed class TakeoutAppDbContext(
|
||||
/// </summary>
|
||||
public DbSet<TenantVerificationProfile> TenantVerificationProfiles => Set<TenantVerificationProfile>();
|
||||
/// <summary>
|
||||
/// 租户账单/配额可见角色规则。
|
||||
/// </summary>
|
||||
public DbSet<TenantVisibilityRoleRule> TenantVisibilityRoleRules => Set<TenantVisibilityRoleRule>();
|
||||
/// <summary>
|
||||
/// 配额包定义。
|
||||
/// </summary>
|
||||
public DbSet<QuotaPackage> QuotaPackages => Set<QuotaPackage>();
|
||||
@@ -394,6 +398,7 @@ public sealed class TakeoutAppDbContext(
|
||||
ConfigureTenantAnnouncement(modelBuilder.Entity<TenantAnnouncement>());
|
||||
ConfigureTenantAnnouncementRead(modelBuilder.Entity<TenantAnnouncementRead>());
|
||||
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
|
||||
ConfigureTenantVisibilityRoleRule(modelBuilder.Entity<TenantVisibilityRoleRule>());
|
||||
ConfigureQuotaPackage(modelBuilder.Entity<QuotaPackage>());
|
||||
ConfigureTenantQuotaPackagePurchase(modelBuilder.Entity<TenantQuotaPackagePurchase>());
|
||||
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
|
||||
@@ -834,6 +839,18 @@ public sealed class TakeoutAppDbContext(
|
||||
builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt });
|
||||
}
|
||||
|
||||
private static void ConfigureTenantVisibilityRoleRule(EntityTypeBuilder<TenantVisibilityRoleRule> builder)
|
||||
{
|
||||
builder.ToTable("tenant_visibility_role_rules");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.QuotaVisibleRoleCodes).HasColumnType("text[]");
|
||||
builder.Property(x => x.BillingVisibleRoleCodes).HasColumnType("text[]");
|
||||
builder.Property(x => x.UpdatedBy).IsRequired();
|
||||
builder.Property(x => x.UpdatedAt).IsRequired();
|
||||
builder.HasIndex(x => x.TenantId).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureTenantAnnouncement(EntityTypeBuilder<TenantAnnouncement> builder)
|
||||
{
|
||||
builder.ToTable("tenant_announcements");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
@@ -9,6 +10,44 @@ namespace TakeoutSaaS.Infrastructure.Logs.Repositories;
|
||||
/// </summary>
|
||||
public sealed class EfOperationLogRepository(TakeoutLogsDbContext logsContext) : IOperationLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<OperationLog> Items, int Total)> SearchByOperatorPagedAsync(
|
||||
long tenantId,
|
||||
string operatorId,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 归一化参数
|
||||
var normalizedOperatorId = operatorId.Trim();
|
||||
var normalizedPage = page <= 0 ? 1 : page;
|
||||
var normalizedPageSize = pageSize <= 0 ? 50 : pageSize;
|
||||
|
||||
// 2. 构建查询(操作人 + 时间窗 + 租户约束)
|
||||
var query = logsContext.OperationLogs
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.OperatorId == normalizedOperatorId
|
||||
&& x.CreatedAt >= from
|
||||
&& x.CreatedAt <= to
|
||||
&& x.Parameters != null
|
||||
&& (EF.Functions.ILike(x.Parameters, $"%\"tenantId\":{tenantId}%")
|
||||
|| EF.Functions.ILike(x.Parameters, $"%\"TenantId\":{tenantId}%")));
|
||||
|
||||
// 3. 查询总数与分页项
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Skip((normalizedPage - 1) * normalizedPageSize)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 4. 返回分页结果
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(OperationLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user