refactor: 移除租户侧能力
This commit is contained in:
@@ -1,303 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单管理。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/billings")]
|
|
||||||
public sealed class BillingsController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询账单列表。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>账单分页结果。</returns>
|
|
||||||
[HttpGet]
|
|
||||||
[PermissionAuthorize("bill:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<BillingListDto>>> GetList([FromQuery] GetBillingListQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单列表
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回分页结果
|
|
||||||
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取账单详情。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">账单 ID。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>账单详情。</returns>
|
|
||||||
[HttpGet("{id:long}")]
|
|
||||||
[PermissionAuthorize("bill:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<BillingDetailDto>> GetDetail(long id, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单详情(若不存在则抛出业务异常,由全局异常处理转换为 404)
|
|
||||||
var result = await mediator.Send(new GetBillingDetailQuery { BillingId = id }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回详情
|
|
||||||
return ApiResponse<BillingDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 手动创建账单。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">创建账单命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>创建的账单信息。</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[PermissionAuthorize("bill:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<BillingDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<BillingDetailDto>> Create([FromBody, Required] CreateBillingCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 创建账单
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回创建结果
|
|
||||||
return ApiResponse<BillingDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新账单状态。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">账单 ID。</param>
|
|
||||||
/// <param name="command">更新状态命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>更新结果。</returns>
|
|
||||||
[HttpPut("{id:long}/status")]
|
|
||||||
[PermissionAuthorize("bill:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<object>> UpdateStatus(long id, [FromBody, Required] UpdateBillingStatusCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定账单标识
|
|
||||||
command = command with { BillingId = id };
|
|
||||||
|
|
||||||
// 2. 更新账单状态(若不存在则抛出业务异常,由全局异常处理转换为 404)
|
|
||||||
await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回成功结果
|
|
||||||
return ApiResponse<object>.Ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 取消账单。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">账单 ID。</param>
|
|
||||||
/// <param name="reason">取消原因(可选)。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>取消结果。</returns>
|
|
||||||
[HttpDelete("{id:long}")]
|
|
||||||
[PermissionAuthorize("bill:delete")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<object>> Cancel(long id, [FromQuery] string? reason, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 取消账单(取消原因支持可选)
|
|
||||||
await mediator.Send(new CancelBillingCommand { BillingId = id, Reason = reason ?? string.Empty }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回成功结果
|
|
||||||
return ApiResponse<object>.Ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取账单支付记录。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">账单 ID。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>支付记录列表。</returns>
|
|
||||||
[HttpGet("{id:long}/payments")]
|
|
||||||
[PermissionAuthorize("bill:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<List<PaymentRecordDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<List<PaymentRecordDto>>> GetPayments(long id, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询支付记录
|
|
||||||
var result = await mediator.Send(new GetBillingPaymentsQuery { BillingId = id }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回列表
|
|
||||||
return ApiResponse<List<PaymentRecordDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 记录支付(线下支付确认)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">账单 ID。</param>
|
|
||||||
/// <param name="command">记录支付命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>支付记录信息。</returns>
|
|
||||||
[HttpPost("{id:long}/payments")]
|
|
||||||
[PermissionAuthorize("bill:pay")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<PaymentRecordDto>> RecordPayment(long id, [FromBody, Required] RecordPaymentCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定账单标识
|
|
||||||
command = command with { BillingId = id };
|
|
||||||
|
|
||||||
// 2. 记录支付
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回支付记录
|
|
||||||
return ApiResponse<PaymentRecordDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 一键确认收款(记录支付 + 立即审核通过)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">账单 ID。</param>
|
|
||||||
/// <param name="command">确认收款命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>确认后的支付记录。</returns>
|
|
||||||
[HttpPost("{id:long}/payments/confirm")]
|
|
||||||
[PermissionAuthorize("bill:pay")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<PaymentRecordDto>> ConfirmPayment(long id, [FromBody, Required] ConfirmPaymentCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定账单标识
|
|
||||||
command = command with { BillingId = id };
|
|
||||||
|
|
||||||
// 2. 一键确认收款(含:写入 VerifiedBy/VerifiedAt,并同步更新账单已收金额/状态)
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回结果
|
|
||||||
return ApiResponse<PaymentRecordDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核支付记录。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="paymentId">支付记录 ID。</param>
|
|
||||||
/// <param name="command">审核参数。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>审核后的支付记录。</returns>
|
|
||||||
[HttpPut("payments/{paymentId:long}/verify")]
|
|
||||||
[PermissionAuthorize("bill:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PaymentRecordDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<PaymentRecordDto>> VerifyPayment(long paymentId, [FromBody, Required] VerifyPaymentCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定支付记录标识
|
|
||||||
command = command with { PaymentId = paymentId };
|
|
||||||
|
|
||||||
// 2. 审核支付记录
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回审核结果
|
|
||||||
return ApiResponse<PaymentRecordDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量更新账单状态。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">批量更新命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>更新条数。</returns>
|
|
||||||
[HttpPost("batch/status")]
|
|
||||||
[PermissionAuthorize("bill:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<int>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<int>> BatchUpdateStatus([FromBody, Required] BatchUpdateStatusCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 执行批量更新
|
|
||||||
var affected = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回更新条数
|
|
||||||
return ApiResponse<int>.Ok(affected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 导出账单(Excel/PDF/CSV)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">导出请求。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>导出文件。</returns>
|
|
||||||
[HttpPost("export")]
|
|
||||||
[PermissionAuthorize("bill:read")]
|
|
||||||
[Produces("application/octet-stream")]
|
|
||||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> Export([FromBody, Required] ExportBillingsQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 执行导出
|
|
||||||
var bytes = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 解析格式并生成文件名
|
|
||||||
var extension = ResolveExportFileExtension(query.Format);
|
|
||||||
var fileName = $"billings_{DateTime.UtcNow:yyyyMMdd_HHmmss}.{extension}";
|
|
||||||
|
|
||||||
// 3. 显式写入 Content-Disposition,确保浏览器以附件形式下载
|
|
||||||
Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment")
|
|
||||||
{
|
|
||||||
FileName = fileName,
|
|
||||||
FileNameStar = fileName
|
|
||||||
}.ToString();
|
|
||||||
|
|
||||||
// 4. 返回二进制流(统一 octet-stream,避免被默认 JSON Produces 影响)
|
|
||||||
return File(bytes, "application/octet-stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取账单统计数据。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">统计查询参数。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>统计结果。</returns>
|
|
||||||
[HttpGet("statistics")]
|
|
||||||
[PermissionAuthorize("bill:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<BillingStatisticsDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<BillingStatisticsDto>> Statistics([FromQuery] GetBillingStatisticsQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询统计数据
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回统计结果
|
|
||||||
return ApiResponse<BillingStatisticsDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取逾期账单列表。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">逾期列表查询参数。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>逾期账单分页结果。</returns>
|
|
||||||
[HttpGet("overdue")]
|
|
||||||
[PermissionAuthorize("bill:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<BillingListDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<BillingListDto>>> Overdue([FromQuery] GetOverdueBillingsQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询逾期账单分页列表
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回分页结果
|
|
||||||
return ApiResponse<PagedResult<BillingListDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolveExportFileExtension(string? format)
|
|
||||||
{
|
|
||||||
// 1. 归一化导出格式
|
|
||||||
var normalized = (format ?? string.Empty).Trim();
|
|
||||||
|
|
||||||
// 2. 映射扩展名
|
|
||||||
return normalized.ToUpperInvariant() switch
|
|
||||||
{
|
|
||||||
"PDF" => "pdf",
|
|
||||||
"CSV" => "csv",
|
|
||||||
_ => "xlsx"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 平台公告管理。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/platform/announcements")]
|
|
||||||
public sealed class PlatformAnnouncementsController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 创建平台公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// POST /api/platform/announcements
|
|
||||||
/// Header: Authorization: Bearer <JWT>
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "title": "平台升级通知",
|
|
||||||
/// "content": "系统将于今晚 23:00 维护。",
|
|
||||||
/// "announcementType": 0,
|
|
||||||
/// "priority": 10,
|
|
||||||
/// "effectiveFrom": "2025-12-20T00:00:00Z",
|
|
||||||
/// "effectiveTo": null,
|
|
||||||
/// "targetType": "all",
|
|
||||||
/// "targetParameters": null
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "tenantId": "0",
|
|
||||||
/// "title": "平台升级通知",
|
|
||||||
/// "status": "Draft"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPost]
|
|
||||||
[PermissionAuthorize("platform-announcement:create")]
|
|
||||||
[SwaggerOperation(Summary = "创建平台公告", Description = "需要权限:platform-announcement:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Create([FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
command = command with
|
|
||||||
{
|
|
||||||
TenantId = 0,
|
|
||||||
PublisherScope = PublisherScope.Platform
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询平台公告列表。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// GET /api/platform/announcements?page=1&pageSize=20&status=Published
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "items": [],
|
|
||||||
/// "page": 1,
|
|
||||||
/// "pageSize": 20,
|
|
||||||
/// "totalCount": 0
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpGet]
|
|
||||||
[PermissionAuthorize("platform-announcement:read", "platform-announcement:create")]
|
|
||||||
[SwaggerOperation(Summary = "查询平台公告列表", Description = "需要权限:platform-announcement:read 或 platform-announcement:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> List([FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var request = query with { TenantId = 0 };
|
|
||||||
var result = await mediator.Send(request, cancellationToken);
|
|
||||||
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取平台公告详情。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// GET /api/platform/announcements/900123456789012345
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "tenantId": "0",
|
|
||||||
/// "title": "平台升级通知",
|
|
||||||
/// "status": "Draft"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpGet("{announcementId:long}")]
|
|
||||||
[PermissionAuthorize("platform-announcement:read", "platform-announcement:create")]
|
|
||||||
[SwaggerOperation(Summary = "获取平台公告详情", Description = "需要权限:platform-announcement:read 或 platform-announcement:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Detail(long announcementId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(new GetAnnouncementByIdQuery { TenantId = 0, AnnouncementId = announcementId }, cancellationToken);
|
|
||||||
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新平台公告(仅草稿)。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// PUT /api/platform/announcements/900123456789012345
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "title": "平台升级通知(更新)",
|
|
||||||
/// "content": "维护时间调整为 23:30。",
|
|
||||||
/// "targetType": "all",
|
|
||||||
/// "targetParameters": null,
|
|
||||||
/// "rowVersion": "AAAAAAAAB9E="
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "status": "Draft"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPut("{announcementId:long}")]
|
|
||||||
[PermissionAuthorize("platform-announcement:create")]
|
|
||||||
[SwaggerOperation(Summary = "更新平台公告", Description = "仅草稿可更新;需要权限:platform-announcement:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Update(long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
command = command with { TenantId = 0, AnnouncementId = announcementId };
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 发布平台公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// POST /api/platform/announcements/900123456789012345/publish
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "rowVersion": "AAAAAAAAB9E="
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "status": "Published"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPost("{announcementId:long}/publish")]
|
|
||||||
[PermissionAuthorize("platform-announcement:publish")]
|
|
||||||
[SwaggerOperation(Summary = "发布平台公告", Description = "需要权限:platform-announcement:publish")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Publish(long announcementId, [FromBody, Required] PublishAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
command = command with { TenantId = 0, AnnouncementId = announcementId };
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 撤销平台公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// POST /api/platform/announcements/900123456789012345/revoke
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "rowVersion": "AAAAAAAAB9E="
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "status": "Revoked"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPost("{announcementId:long}/revoke")]
|
|
||||||
[PermissionAuthorize("platform-announcement:revoke")]
|
|
||||||
[SwaggerOperation(Summary = "撤销平台公告", Description = "需要权限:platform-announcement:revoke")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Revoke(long announcementId, [FromBody, Required] RevokeAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
command = command with { TenantId = 0, AnnouncementId = announcementId };
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包管理。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/quota-packages")]
|
|
||||||
public sealed class QuotaPackagesController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包列表。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">查询条件。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>配额包分页结果。</returns>
|
|
||||||
[HttpGet]
|
|
||||||
[PermissionAuthorize("quota-package:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<QuotaPackageListDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<QuotaPackageListDto>>> List([FromQuery] GetQuotaPackageListQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询配额包分页
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回结果
|
|
||||||
return ApiResponse<PagedResult<QuotaPackageListDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建配额包。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">创建命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>创建后的配额包。</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[PermissionAuthorize("quota-package:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<QuotaPackageDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<QuotaPackageDto>> Create([FromBody, Required] CreateQuotaPackageCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 执行创建
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回创建结果
|
|
||||||
return ApiResponse<QuotaPackageDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新配额包。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quotaPackageId">配额包 ID。</param>
|
|
||||||
/// <param name="command">更新命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>更新后的配额包或未找到。</returns>
|
|
||||||
[HttpPut("{quotaPackageId:long}")]
|
|
||||||
[PermissionAuthorize("quota-package:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<QuotaPackageDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<QuotaPackageDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<QuotaPackageDto>> Update(long quotaPackageId, [FromBody, Required] UpdateQuotaPackageCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定路由 ID
|
|
||||||
command = command with { QuotaPackageId = quotaPackageId };
|
|
||||||
|
|
||||||
// 2. 执行更新
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回更新结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<QuotaPackageDto>.Error(StatusCodes.Status404NotFound, "配额包不存在")
|
|
||||||
: ApiResponse<QuotaPackageDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 删除配额包。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quotaPackageId">配额包 ID。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>删除结果。</returns>
|
|
||||||
[HttpDelete("{quotaPackageId:long}")]
|
|
||||||
[PermissionAuthorize("quota-package:delete")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<bool>> Delete(long quotaPackageId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 构建删除命令
|
|
||||||
var command = new DeleteQuotaPackageCommand { QuotaPackageId = quotaPackageId };
|
|
||||||
|
|
||||||
// 2. 执行删除并返回
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<bool>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 上架/下架配额包。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quotaPackageId">配额包 ID。</param>
|
|
||||||
/// <param name="command">状态更新命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>更新结果。</returns>
|
|
||||||
[HttpPut("{quotaPackageId:long}/status")]
|
|
||||||
[PermissionAuthorize("quota-package:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<bool>> UpdateStatus(long quotaPackageId, [FromBody, Required] UpdateQuotaPackageStatusCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定路由 ID
|
|
||||||
command = command with { QuotaPackageId = quotaPackageId };
|
|
||||||
|
|
||||||
// 2. 执行状态更新
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回结果
|
|
||||||
return ApiResponse<bool>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 为租户购买配额包。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
|
||||||
/// <param name="command">购买命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>购买记录。</returns>
|
|
||||||
[HttpPost("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-packages")]
|
|
||||||
[PermissionAuthorize("tenant:quota:purchase")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantQuotaPurchaseDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantQuotaPurchaseDto>> PurchaseForTenant(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody, Required] PurchaseQuotaPackageCommand command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户 ID
|
|
||||||
command = command with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 执行购买
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回购买结果
|
|
||||||
return ApiResponse<TenantQuotaPurchaseDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户配额使用情况。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
|
||||||
/// <param name="query">查询条件。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>配额使用情况列表。</returns>
|
|
||||||
[HttpGet("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-usage")]
|
|
||||||
[PermissionAuthorize("tenant:quota:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>> GetTenantQuotaUsage(
|
|
||||||
long tenantId,
|
|
||||||
[FromQuery] GetTenantQuotaUsageQuery query,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户 ID
|
|
||||||
query = query with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 查询配额使用情况
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回结果
|
|
||||||
return ApiResponse<IReadOnlyList<TenantQuotaUsageDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户配额购买记录。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
|
||||||
/// <param name="query">查询条件。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>购买记录分页结果。</returns>
|
|
||||||
[HttpGet("~/api/admin/v{version:apiVersion}/tenants/{tenantId:long}/quota-purchases")]
|
|
||||||
[PermissionAuthorize("tenant:quota:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantQuotaPurchaseDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<TenantQuotaPurchaseDto>>> GetTenantQuotaPurchases(
|
|
||||||
long tenantId,
|
|
||||||
[FromQuery] GetTenantQuotaPurchasesQuery query,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户 ID
|
|
||||||
query = query with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 查询购买记录
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回结果
|
|
||||||
return ApiResponse<PagedResult<TenantQuotaPurchaseDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统计数据接口。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/statistics")]
|
|
||||||
public sealed class StatisticsController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 获取订阅概览统计。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>订阅概览数据。</returns>
|
|
||||||
[HttpGet("subscription-overview")]
|
|
||||||
[PermissionAuthorize("statistics:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionOverviewDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<SubscriptionOverviewDto>> GetSubscriptionOverview(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(new GetSubscriptionOverviewQuery(), cancellationToken);
|
|
||||||
return ApiResponse<SubscriptionOverviewDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取配额使用排行。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="quotaType">配额类型。</param>
|
|
||||||
/// <param name="topN">返回前N条记录,默认10。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>配额使用排行数据。</returns>
|
|
||||||
[HttpGet("quota-ranking")]
|
|
||||||
[PermissionAuthorize("statistics:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<QuotaUsageRankingDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<QuotaUsageRankingDto>> GetQuotaRanking(
|
|
||||||
[FromQuery] TenantQuotaType quotaType,
|
|
||||||
[FromQuery] int topN = 10,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = new GetQuotaUsageRankingQuery { QuotaType = quotaType, TopN = topN };
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
return ApiResponse<QuotaUsageRankingDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取收入统计。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="monthsCount">统计月份数量,默认12个月。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>收入统计数据。</returns>
|
|
||||||
[HttpGet("revenue")]
|
|
||||||
[PermissionAuthorize("statistics:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<RevenueStatisticsDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<RevenueStatisticsDto>> GetRevenue(
|
|
||||||
[FromQuery] int monthsCount = 12,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = new GetRevenueStatisticsQuery { MonthsCount = monthsCount };
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
return ApiResponse<RevenueStatisticsDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取即将到期的订阅列表。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="daysAhead">筛选天数,默认7天内到期。</param>
|
|
||||||
/// <param name="onlyWithoutAutoRenew">是否只返回未开启自动续费的订阅。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>即将到期的订阅列表。</returns>
|
|
||||||
[HttpGet("expiring-subscriptions")]
|
|
||||||
[PermissionAuthorize("statistics:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ExpiringSubscriptionDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<IReadOnlyList<ExpiringSubscriptionDto>>> GetExpiringSubscriptions(
|
|
||||||
[FromQuery] int daysAhead = 7,
|
|
||||||
[FromQuery] bool onlyWithoutAutoRenew = false,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var query = new GetExpiringSubscriptionsQuery
|
|
||||||
{
|
|
||||||
DaysAhead = daysAhead,
|
|
||||||
OnlyWithoutAutoRenew = onlyWithoutAutoRenew
|
|
||||||
};
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
return ApiResponse<IReadOnlyList<ExpiringSubscriptionDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Subscriptions.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Subscriptions.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Subscriptions.Queries;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅管理。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/subscriptions")]
|
|
||||||
public sealed class SubscriptionsController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询订阅列表(支持按状态、套餐、到期时间筛选)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">查询条件。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>订阅分页结果。</returns>
|
|
||||||
[HttpGet]
|
|
||||||
[PermissionAuthorize("subscription:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<SubscriptionListDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<SubscriptionListDto>>> List(
|
|
||||||
[FromQuery] GetSubscriptionListQuery query,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询订阅分页
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回结果
|
|
||||||
return ApiResponse<PagedResult<SubscriptionListDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查看订阅详情(含套餐信息、配额使用、变更历史)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="subscriptionId">订阅 ID。</param>
|
|
||||||
/// <param name="includeDeleted">是否包含已软删除数据。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>订阅详情或未找到。</returns>
|
|
||||||
[HttpGet("{subscriptionId:long}")]
|
|
||||||
[PermissionAuthorize("subscription:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<SubscriptionDetailDto>> Detail(
|
|
||||||
long subscriptionId,
|
|
||||||
[FromQuery] bool includeDeleted,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询订阅详情
|
|
||||||
var result = await mediator.Send(new GetSubscriptionDetailQuery
|
|
||||||
{
|
|
||||||
SubscriptionId = subscriptionId,
|
|
||||||
IncludeDeleted = includeDeleted
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回查询结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
|
||||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新订阅基础信息(备注、自动续费等)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="subscriptionId">订阅 ID。</param>
|
|
||||||
/// <param name="command">更新命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>更新后的订阅详情或未找到。</returns>
|
|
||||||
[HttpPut("{subscriptionId:long}")]
|
|
||||||
[PermissionAuthorize("subscription:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<SubscriptionDetailDto>> Update(
|
|
||||||
long subscriptionId,
|
|
||||||
[FromBody, Required] UpdateSubscriptionCommand command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定路由 ID
|
|
||||||
command = command with { SubscriptionId = subscriptionId };
|
|
||||||
|
|
||||||
// 2. 执行更新
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回更新结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
|
||||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 延期订阅(增加订阅时长)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="subscriptionId">订阅 ID。</param>
|
|
||||||
/// <param name="command">延期命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>延期后的订阅详情或未找到。</returns>
|
|
||||||
[HttpPost("{subscriptionId:long}/extend")]
|
|
||||||
[PermissionAuthorize("subscription:extend")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<SubscriptionDetailDto>> Extend(
|
|
||||||
long subscriptionId,
|
|
||||||
[FromBody, Required] ExtendSubscriptionCommand command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定路由 ID
|
|
||||||
command = command with { SubscriptionId = subscriptionId };
|
|
||||||
|
|
||||||
// 2. 执行延期
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回延期结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
|
||||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 变更套餐(支持立即生效或下周期生效)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="subscriptionId">订阅 ID。</param>
|
|
||||||
/// <param name="command">变更套餐命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>变更后的订阅详情或未找到。</returns>
|
|
||||||
[HttpPost("{subscriptionId:long}/change-plan")]
|
|
||||||
[PermissionAuthorize("subscription:change-plan")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<SubscriptionDetailDto>> ChangePlan(
|
|
||||||
long subscriptionId,
|
|
||||||
[FromBody, Required] ChangeSubscriptionPlanCommand command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定路由 ID
|
|
||||||
command = command with { SubscriptionId = subscriptionId };
|
|
||||||
|
|
||||||
// 2. 执行套餐变更
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回变更结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
|
||||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 变更订阅状态。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="subscriptionId">订阅 ID。</param>
|
|
||||||
/// <param name="command">状态变更命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>变更后的订阅详情或未找到。</returns>
|
|
||||||
[HttpPost("{subscriptionId:long}/status")]
|
|
||||||
[PermissionAuthorize("subscription:update-status")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<SubscriptionDetailDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<SubscriptionDetailDto>> UpdateStatus(
|
|
||||||
long subscriptionId,
|
|
||||||
[FromBody, Required] UpdateSubscriptionStatusCommand command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定路由 ID
|
|
||||||
command = command with { SubscriptionId = subscriptionId };
|
|
||||||
|
|
||||||
// 2. 执行状态变更
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回变更结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<SubscriptionDetailDto>.Error(StatusCodes.Status404NotFound, "订阅不存在")
|
|
||||||
: ApiResponse<SubscriptionDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量延期订阅。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">批量延期命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>批量延期结果。</returns>
|
|
||||||
[HttpPost("batch-extend")]
|
|
||||||
[PermissionAuthorize("subscription:batch-extend")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<BatchExtendResult>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<BatchExtendResult>> BatchExtend(
|
|
||||||
[FromBody, Required] BatchExtendSubscriptionsCommand command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<BatchExtendResult>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量发送续费提醒。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">批量发送提醒命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>批量发送提醒结果。</returns>
|
|
||||||
[HttpPost("batch-remind")]
|
|
||||||
[PermissionAuthorize("subscription:batch-remind")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<BatchSendReminderResult>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<BatchSendReminderResult>> BatchRemind(
|
|
||||||
[FromBody, Required] BatchSendReminderCommand command,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<BatchSendReminderResult>.Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户公告管理。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/announcements")]
|
|
||||||
public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// GET /api/admin/v1/tenants/100000000000000001/announcements?page=1&pageSize=20
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "items": [],
|
|
||||||
/// "page": 1,
|
|
||||||
/// "pageSize": 20,
|
|
||||||
/// "totalCount": 0
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpGet]
|
|
||||||
[PermissionAuthorize("tenant-announcement:read")]
|
|
||||||
[SwaggerOperation(Summary = "查询租户公告列表", Description = "需要权限:tenant-announcement:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> Search(long tenantId, [FromQuery] GetTenantsAnnouncementsQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户标识
|
|
||||||
if (tenantId <= 0)
|
|
||||||
{
|
|
||||||
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 绑定路由租户并查询列表
|
|
||||||
var request = query with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 3. (空行后) 执行查询
|
|
||||||
var result = await mediator.Send(request, cancellationToken);
|
|
||||||
|
|
||||||
// 4. (空行后) 返回分页结果
|
|
||||||
return ApiResponse<PagedResult<TenantAnnouncementDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 公告详情。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// GET /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "tenantId": "100000000000000001",
|
|
||||||
/// "title": "租户公告",
|
|
||||||
/// "status": "Draft"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpGet("{announcementId:long}")]
|
|
||||||
[PermissionAuthorize("tenant-announcement:read")]
|
|
||||||
[SwaggerOperation(Summary = "获取公告详情", Description = "需要权限:tenant-announcement:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Detail(long tenantId, long announcementId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户标识
|
|
||||||
if (tenantId <= 0)
|
|
||||||
{
|
|
||||||
return ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 查询公告详情
|
|
||||||
var query = new GetAnnouncementByIdQuery { TenantId = tenantId, AnnouncementId = announcementId };
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 3. (空行后) 返回详情或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// POST /api/admin/v1/tenants/100000000000000001/announcements
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "title": "租户公告",
|
|
||||||
/// "content": "新品上线提醒",
|
|
||||||
/// "announcementType": 0,
|
|
||||||
/// "priority": 5,
|
|
||||||
/// "effectiveFrom": "2025-12-20T00:00:00Z",
|
|
||||||
/// "targetType": "roles",
|
|
||||||
/// "targetParameters": "{\"roles\":[\"OpsManager\"]}"
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "tenantId": "100000000000000001",
|
|
||||||
/// "title": "租户公告",
|
|
||||||
/// "status": "Draft"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPost]
|
|
||||||
[PermissionAuthorize("tenant-announcement:create")]
|
|
||||||
[SwaggerOperation(Summary = "创建租户公告", Description = "需要权限:tenant-announcement:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Create(long tenantId, [FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户标识
|
|
||||||
if (tenantId <= 0)
|
|
||||||
{
|
|
||||||
return ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 绑定租户标识并创建公告
|
|
||||||
command = command with { TenantId = tenantId };
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新公告(仅草稿)。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// PUT /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "title": "租户公告(更新)",
|
|
||||||
/// "content": "公告内容更新",
|
|
||||||
/// "targetType": "all",
|
|
||||||
/// "targetParameters": null,
|
|
||||||
/// "rowVersion": "AAAAAAAAB9E="
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "status": "Draft"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPut("{announcementId:long}")]
|
|
||||||
[PermissionAuthorize("tenant-announcement:update")]
|
|
||||||
[SwaggerOperation(Summary = "更新租户公告", Description = "仅草稿可更新;需要权限:tenant-announcement:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Update(long tenantId, long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户标识
|
|
||||||
if (tenantId <= 0)
|
|
||||||
{
|
|
||||||
return ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 执行更新
|
|
||||||
command = command with { TenantId = tenantId, AnnouncementId = announcementId };
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 发布公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// POST /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345/publish
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "rowVersion": "AAAAAAAAB9E="
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "status": "Published"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPost("{announcementId:long}/publish")]
|
|
||||||
[PermissionAuthorize("tenant-announcement:publish")]
|
|
||||||
[SwaggerOperation(Summary = "发布租户公告", Description = "需要权限:tenant-announcement:publish")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Publish(long tenantId, long announcementId, [FromBody, Required] PublishAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户标识
|
|
||||||
if (tenantId <= 0)
|
|
||||||
{
|
|
||||||
return ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 发布公告
|
|
||||||
command = command with { TenantId = tenantId, AnnouncementId = announcementId };
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 撤销公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// POST /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345/revoke
|
|
||||||
/// Body:
|
|
||||||
/// {
|
|
||||||
/// "rowVersion": "AAAAAAAAB9E="
|
|
||||||
/// }
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": {
|
|
||||||
/// "id": "900123456789012345",
|
|
||||||
/// "status": "Revoked"
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpPost("{announcementId:long}/revoke")]
|
|
||||||
[PermissionAuthorize("tenant-announcement:revoke")]
|
|
||||||
[SwaggerOperation(Summary = "撤销租户公告", Description = "需要权限:tenant-announcement:revoke")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<TenantAnnouncementDto>> Revoke(long tenantId, long announcementId, [FromBody, Required] RevokeAnnouncementCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户标识
|
|
||||||
if (tenantId <= 0)
|
|
||||||
{
|
|
||||||
return ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 撤销公告
|
|
||||||
command = command with { TenantId = tenantId, AnnouncementId = announcementId };
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
|
||||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 删除公告。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// 示例:
|
|
||||||
/// <code>
|
|
||||||
/// DELETE /api/admin/v1/tenants/100000000000000001/announcements/900123456789012345
|
|
||||||
/// 响应:
|
|
||||||
/// {
|
|
||||||
/// "success": true,
|
|
||||||
/// "code": 200,
|
|
||||||
/// "data": true
|
|
||||||
/// }
|
|
||||||
/// </code>
|
|
||||||
/// </remarks>
|
|
||||||
[HttpDelete("{announcementId:long}")]
|
|
||||||
[PermissionAuthorize("tenant-announcement:delete")]
|
|
||||||
[SwaggerOperation(Summary = "删除租户公告", Description = "需要权限:tenant-announcement:delete")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
|
||||||
public async Task<ApiResponse<bool>> Delete(long tenantId, long announcementId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户标识
|
|
||||||
if (tenantId <= 0)
|
|
||||||
{
|
|
||||||
return ApiResponse<bool>.Error(StatusCodes.Status400BadRequest, "租户标识无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 执行删除
|
|
||||||
var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
|
|
||||||
return ApiResponse<bool>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户套餐管理。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/tenant-packages")]
|
|
||||||
public sealed class TenantPackagesController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询租户套餐。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">查询条件。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>租户套餐分页结果。</returns>
|
|
||||||
[HttpGet]
|
|
||||||
[PermissionAuthorize("tenant-package:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<TenantPackageDto>>> Search([FromQuery] SearchTenantPackagesQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询租户套餐分页
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回结果
|
|
||||||
return ApiResponse<PagedResult<TenantPackageDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询套餐使用统计(订阅关联数量、使用租户数量)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantPackageIds">套餐 ID 列表(为空表示查询全部)。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>套餐使用统计列表。</returns>
|
|
||||||
[HttpGet("usages")]
|
|
||||||
[PermissionAuthorize("tenant-package:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TenantPackageUsageDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<IReadOnlyList<TenantPackageUsageDto>>> Usages(
|
|
||||||
[FromQuery] long[]? tenantPackageIds,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询使用统计
|
|
||||||
var result = await mediator.Send(new GetTenantPackageUsagesQuery { TenantPackageIds = tenantPackageIds }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回结果
|
|
||||||
return ApiResponse<IReadOnlyList<TenantPackageUsageDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询套餐当前使用租户列表(按有效订阅口径)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
|
||||||
/// <param name="keyword">关键词(可选)。</param>
|
|
||||||
/// <param name="expiringWithinDays">可选:未来 N 天内到期筛选。</param>
|
|
||||||
/// <param name="page">页码(从 1 开始)。</param>
|
|
||||||
/// <param name="pageSize">每页大小。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>使用租户分页结果。</returns>
|
|
||||||
[HttpGet("{tenantPackageId:long}/tenants")]
|
|
||||||
[PermissionAuthorize("tenant-package:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageTenantDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<TenantPackageTenantDto>>> Tenants(
|
|
||||||
long tenantPackageId,
|
|
||||||
[FromQuery] string? keyword,
|
|
||||||
[FromQuery] int? expiringWithinDays,
|
|
||||||
[FromQuery] int page = 1,
|
|
||||||
[FromQuery] int pageSize = 20,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// 1. 查询套餐使用租户分页
|
|
||||||
var result = await mediator.Send(new GetTenantPackageTenantsQuery
|
|
||||||
{
|
|
||||||
TenantPackageId = tenantPackageId,
|
|
||||||
Keyword = keyword,
|
|
||||||
ExpiringWithinDays = expiringWithinDays,
|
|
||||||
Page = page,
|
|
||||||
PageSize = pageSize
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回结果
|
|
||||||
return ApiResponse<PagedResult<TenantPackageTenantDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查看套餐详情。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>套餐详情或未找到。</returns>
|
|
||||||
[HttpGet("{tenantPackageId:long}")]
|
|
||||||
[PermissionAuthorize("tenant-package:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<TenantPackageDto>> Detail(long tenantPackageId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询套餐详情
|
|
||||||
var result = await mediator.Send(new GetTenantPackageByIdQuery { TenantPackageId = tenantPackageId }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回查询结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantPackageDto>.Error(StatusCodes.Status404NotFound, "套餐不存在")
|
|
||||||
: ApiResponse<TenantPackageDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建套餐。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">创建命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>创建后的套餐。</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[PermissionAuthorize("tenant-package:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantPackageDto>> Create([FromBody, Required] CreateTenantPackageCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 执行创建
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回创建结果
|
|
||||||
return ApiResponse<TenantPackageDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新套餐。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
|
||||||
/// <param name="command">更新命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>更新后的套餐或未找到。</returns>
|
|
||||||
[HttpPut("{tenantPackageId:long}")]
|
|
||||||
[PermissionAuthorize("tenant-package:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ApiResponse<TenantPackageDto>> Update(long tenantPackageId, [FromBody, Required] UpdateTenantPackageCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定路由 ID
|
|
||||||
command = command with { TenantPackageId = tenantPackageId };
|
|
||||||
|
|
||||||
// 2. 执行更新
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回更新结果或 404
|
|
||||||
return result is null
|
|
||||||
? ApiResponse<TenantPackageDto>.Error(StatusCodes.Status404NotFound, "套餐不存在")
|
|
||||||
: ApiResponse<TenantPackageDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 删除套餐。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>删除结果。</returns>
|
|
||||||
[HttpDelete("{tenantPackageId:long}")]
|
|
||||||
[PermissionAuthorize("tenant-package:delete")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<bool>> Delete(long tenantPackageId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 构建删除命令
|
|
||||||
var command = new DeleteTenantPackageCommand { TenantPackageId = tenantPackageId };
|
|
||||||
|
|
||||||
// 2. 执行删除并返回
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<bool>.Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
|
||||||
using TakeoutSaaS.Module.Authorization.Attributes;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
using TakeoutSaaS.Shared.Web.Api;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户管理。
|
|
||||||
/// </summary>
|
|
||||||
[ApiVersion("1.0")]
|
|
||||||
[Authorize]
|
|
||||||
[Route("api/admin/v{version:apiVersion}/tenants")]
|
|
||||||
public sealed class TenantsController(IMediator mediator) : BaseApiController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 注册租户并初始化套餐。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>注册的租户信息。</returns>
|
|
||||||
[HttpPost]
|
|
||||||
[PermissionAuthorize("tenant:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantDto>> Register([FromBody] RegisterTenantCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 注册租户并初始化套餐
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回注册结果
|
|
||||||
return ApiResponse<TenantDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 后台手动新增租户并直接入驻(创建租户 + 认证 + 订阅 + 管理员账号)。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>新增后的租户详情。</returns>
|
|
||||||
[HttpPost("manual")]
|
|
||||||
[PermissionAuthorize("tenant:create")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantDetailDto>> CreateManually([FromBody] CreateTenantManuallyCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 后台手动新增租户(直接可用)
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回创建结果
|
|
||||||
return ApiResponse<TenantDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询租户。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>租户分页结果。</returns>
|
|
||||||
[HttpGet]
|
|
||||||
[PermissionAuthorize("tenant:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<TenantDto>>> Search([FromQuery] SearchTenantsQuery query, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询租户分页
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回分页数据
|
|
||||||
return ApiResponse<PagedResult<TenantDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查看租户详情。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>租户详情。</returns>
|
|
||||||
[HttpGet("{tenantId:long}")]
|
|
||||||
[PermissionAuthorize("tenant:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantDetailDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantDetailDto>> Detail(long tenantId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询租户详情
|
|
||||||
var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回租户信息
|
|
||||||
return ApiResponse<TenantDetailDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新租户基础信息。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
|
||||||
/// <param name="body">更新命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>更新结果。</returns>
|
|
||||||
[HttpPut("{tenantId:long}")]
|
|
||||||
[PermissionAuthorize("tenant:update")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<object>> Update(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody, Required] UpdateTenantCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验路由与请求体租户标识一致
|
|
||||||
if (body.TenantId != 0 && body.TenantId != tenantId)
|
|
||||||
{
|
|
||||||
return ApiResponse<object>.Error(StatusCodes.Status400BadRequest, "路由 tenantId 与请求体 tenantId 不一致");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 绑定租户标识并执行更新(若不存在或冲突则抛出业务异常,由全局异常处理转换为 404/409)
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回成功结果
|
|
||||||
return ApiResponse<object>.Ok(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提交或更新实名认证资料。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>提交的实名认证信息。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/verification")]
|
|
||||||
[PermissionAuthorize("tenant:review")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantVerificationDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantVerificationDto>> SubmitVerification(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody] SubmitTenantVerificationCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 合并路由中的租户标识
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 提交或更新认证资料
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回认证结果
|
|
||||||
return ApiResponse<TenantVerificationDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核租户。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>审核后的租户信息。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/review")]
|
|
||||||
[PermissionAuthorize("tenant:review")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantDto>> Review(long tenantId, [FromBody] ReviewTenantCommand body, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户标识
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 执行审核
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回审核结果
|
|
||||||
return ApiResponse<TenantDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询当前租户审核领取信息。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>领取信息,未领取返回 null。</returns>
|
|
||||||
[HttpGet("{tenantId:long}/review/claim")]
|
|
||||||
[PermissionAuthorize("tenant:review")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantReviewClaimDto?>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantReviewClaimDto?>> GetReviewClaim(long tenantId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询领取信息
|
|
||||||
var result = await mediator.Send(new GetTenantReviewClaimQuery(tenantId), cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回领取信息
|
|
||||||
return ApiResponse<TenantReviewClaimDto?>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 领取租户入驻审核(领取后仅领取人可操作审核)。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>领取结果。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/review/claim")]
|
|
||||||
[PermissionAuthorize("tenant:review")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantReviewClaimDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantReviewClaimDto>> ClaimReview(long tenantId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 执行领取
|
|
||||||
var result = await mediator.Send(new ClaimTenantReviewCommand { TenantId = tenantId }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回领取结果
|
|
||||||
return ApiResponse<TenantReviewClaimDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 强制接管租户入驻审核(仅超级管理员可用)。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>接管后的领取信息。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/review/force-claim")]
|
|
||||||
[PermissionAuthorize("tenant:review:force-claim")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantReviewClaimDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantReviewClaimDto>> ForceClaimReview(long tenantId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 执行强制接管
|
|
||||||
var result = await mediator.Send(new ForceClaimTenantReviewCommand { TenantId = tenantId }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回接管结果
|
|
||||||
return ApiResponse<TenantReviewClaimDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 释放租户入驻审核领取(仅领取人可释放)。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>释放后的领取信息,未领取返回 null。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/review/release")]
|
|
||||||
[PermissionAuthorize("tenant:review")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantReviewClaimDto?>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantReviewClaimDto?>> ReleaseReview(long tenantId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 执行释放
|
|
||||||
var result = await mediator.Send(new ReleaseTenantReviewClaimCommand { TenantId = tenantId }, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 返回释放结果
|
|
||||||
return ApiResponse<TenantReviewClaimDto?>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 冻结租户(暂停服务)。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>冻结后的租户信息。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/freeze")]
|
|
||||||
[PermissionAuthorize("tenant:review")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantDto>> Freeze(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody] FreezeTenantCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 合并路由参数
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 执行冻结
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回冻结结果
|
|
||||||
return ApiResponse<TenantDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 解冻租户(恢复服务)。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>解冻后的租户信息。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/unfreeze")]
|
|
||||||
[PermissionAuthorize("tenant:review")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantDto>> Unfreeze(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody] UnfreezeTenantCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 合并路由参数
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 执行解冻
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回解冻结果
|
|
||||||
return ApiResponse<TenantDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建或续费租户订阅。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>创建或续费的订阅信息。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/subscriptions")]
|
|
||||||
[PermissionAuthorize("tenant:subscription")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantSubscriptionDto>> CreateSubscription(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody] CreateTenantSubscriptionCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户并创建或续费订阅
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 返回订阅结果
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<TenantSubscriptionDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 延期/赠送租户订阅时长(按当前订阅套餐续费)。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>续费后的订阅信息。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/subscriptions/extend")]
|
|
||||||
[PermissionAuthorize("tenant:subscription")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantSubscriptionDto>> ExtendSubscription(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody] ExtendTenantSubscriptionCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 合并租户标识
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 执行延期/赠送
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回订阅结果
|
|
||||||
return ApiResponse<TenantSubscriptionDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 套餐升降配。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>更新后的订阅信息。</returns>
|
|
||||||
[HttpPut("{tenantId:long}/subscriptions/{subscriptionId:long}/plan")]
|
|
||||||
[PermissionAuthorize("tenant:subscription")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<TenantSubscriptionDto>> ChangePlan(
|
|
||||||
long tenantId,
|
|
||||||
long subscriptionId,
|
|
||||||
[FromBody] ChangeTenantSubscriptionPlanCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户与订阅标识
|
|
||||||
var command = body with { TenantId = tenantId, TenantSubscriptionId = subscriptionId };
|
|
||||||
|
|
||||||
// 2. 执行升降配
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回调整后的订阅
|
|
||||||
return ApiResponse<TenantSubscriptionDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询审核日志。
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>租户审核日志分页结果。</returns>
|
|
||||||
[HttpGet("{tenantId:long}/audits")]
|
|
||||||
[PermissionAuthorize("tenant:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAuditLogDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<TenantAuditLogDto>>> AuditLogs(
|
|
||||||
long tenantId,
|
|
||||||
[FromQuery] int page = 1,
|
|
||||||
[FromQuery] int pageSize = 20,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// 1. 构造审核日志查询
|
|
||||||
var query = new GetTenantAuditLogsQuery(tenantId, page, pageSize);
|
|
||||||
|
|
||||||
// 2. 查询并返回分页结果
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
return ApiResponse<PagedResult<TenantAuditLogDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额校验并占用额度(门店/账号/短信/配送)。
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>租户标识来自路由参数 tenantId,无需强制使用租户请求头。</remarks>
|
|
||||||
/// <returns>配额校验结果。</returns>
|
|
||||||
[HttpPost("{tenantId:long}/quotas/check")]
|
|
||||||
[PermissionAuthorize("tenant:quota:check")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<QuotaCheckResultDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<QuotaCheckResultDto>> CheckQuota(
|
|
||||||
long tenantId,
|
|
||||||
[FromBody, Required] CheckTenantQuotaCommand body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户标识
|
|
||||||
var command = body with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 校验并占用配额
|
|
||||||
var result = await mediator.Send(command, cancellationToken);
|
|
||||||
return ApiResponse<QuotaCheckResultDto>.Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询租户配额使用历史。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="tenantId">租户 ID。</param>
|
|
||||||
/// <param name="query">查询条件。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>配额使用历史分页结果。</returns>
|
|
||||||
[HttpGet("{tenantId:long}/quota-usage-history")]
|
|
||||||
[PermissionAuthorize("tenant:quota:read")]
|
|
||||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<QuotaUsageHistoryDto>>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<ApiResponse<PagedResult<QuotaUsageHistoryDto>>> GetQuotaUsageHistory(
|
|
||||||
long tenantId,
|
|
||||||
[FromQuery] GetTenantQuotaUsageHistoryQuery query,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 绑定租户标识
|
|
||||||
query = query with { TenantId = tenantId };
|
|
||||||
|
|
||||||
// 2. 查询配额使用历史
|
|
||||||
var result = await mediator.Send(query, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回分页结果
|
|
||||||
return ApiResponse<PagedResult<QuotaUsageHistoryDto>>.Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 DTO 映射助手。
|
|
||||||
/// </summary>
|
|
||||||
internal static class BillingMapping
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 将账单实体映射为账单 DTO(旧版)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="bill">账单实体。</param>
|
|
||||||
/// <param name="tenantName">租户名称。</param>
|
|
||||||
/// <returns>账单 DTO。</returns>
|
|
||||||
public static BillDto ToDto(this TenantBillingStatement bill, string? tenantName = null)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = bill.Id,
|
|
||||||
TenantId = bill.TenantId,
|
|
||||||
TenantName = tenantName,
|
|
||||||
StatementNo = bill.StatementNo,
|
|
||||||
PeriodStart = bill.PeriodStart,
|
|
||||||
PeriodEnd = bill.PeriodEnd,
|
|
||||||
AmountDue = bill.AmountDue,
|
|
||||||
AmountPaid = bill.AmountPaid,
|
|
||||||
Status = bill.Status,
|
|
||||||
DueDate = bill.DueDate,
|
|
||||||
CreatedAt = bill.CreatedAt
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将账单实体映射为账单列表 DTO(新版)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="billing">账单实体。</param>
|
|
||||||
/// <param name="tenantName">租户名称。</param>
|
|
||||||
/// <returns>账单列表 DTO。</returns>
|
|
||||||
public static BillingListDto ToBillingListDto(this TenantBillingStatement billing, string? tenantName = null)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = billing.Id,
|
|
||||||
TenantId = billing.TenantId,
|
|
||||||
SubscriptionId = billing.SubscriptionId,
|
|
||||||
TenantName = tenantName ?? string.Empty,
|
|
||||||
StatementNo = billing.StatementNo,
|
|
||||||
BillingType = billing.BillingType,
|
|
||||||
Status = billing.Status,
|
|
||||||
PeriodStart = billing.PeriodStart,
|
|
||||||
PeriodEnd = billing.PeriodEnd,
|
|
||||||
AmountDue = billing.AmountDue,
|
|
||||||
AmountPaid = billing.AmountPaid,
|
|
||||||
DiscountAmount = billing.DiscountAmount,
|
|
||||||
TaxAmount = billing.TaxAmount,
|
|
||||||
TotalAmount = billing.CalculateTotalAmount(),
|
|
||||||
Currency = billing.Currency,
|
|
||||||
DueDate = billing.DueDate,
|
|
||||||
CreatedAt = billing.CreatedAt,
|
|
||||||
UpdatedAt = billing.UpdatedAt,
|
|
||||||
IsOverdue = billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue
|
|
||||||
|| (billing.Status == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending && billing.DueDate < DateTime.UtcNow),
|
|
||||||
OverdueDays = (billing.Status is TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Pending
|
|
||||||
or TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Overdue)
|
|
||||||
&& billing.DueDate < DateTime.UtcNow
|
|
||||||
? (int)(DateTime.UtcNow - billing.DueDate).TotalDays
|
|
||||||
: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将账单实体与支付记录映射为账单详情 DTO(旧版)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="bill">账单实体。</param>
|
|
||||||
/// <param name="payments">支付记录列表。</param>
|
|
||||||
/// <param name="tenantName">租户名称。</param>
|
|
||||||
/// <returns>账单详情 DTO。</returns>
|
|
||||||
public static BillDetailDto ToDetailDto(
|
|
||||||
this TenantBillingStatement bill,
|
|
||||||
List<TenantPayment> payments,
|
|
||||||
string? tenantName = null)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = bill.Id,
|
|
||||||
TenantId = bill.TenantId,
|
|
||||||
TenantName = tenantName,
|
|
||||||
StatementNo = bill.StatementNo,
|
|
||||||
PeriodStart = bill.PeriodStart,
|
|
||||||
PeriodEnd = bill.PeriodEnd,
|
|
||||||
AmountDue = bill.AmountDue,
|
|
||||||
AmountPaid = bill.AmountPaid,
|
|
||||||
Status = bill.Status,
|
|
||||||
DueDate = bill.DueDate,
|
|
||||||
LineItemsJson = bill.LineItemsJson,
|
|
||||||
CreatedAt = bill.CreatedAt,
|
|
||||||
Payments = payments.Select(p => p.ToDto()).ToList()
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将账单实体与支付记录映射为账单详情 DTO(新版)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="billing">账单实体。</param>
|
|
||||||
/// <param name="payments">支付记录列表。</param>
|
|
||||||
/// <param name="tenantName">租户名称。</param>
|
|
||||||
/// <returns>账单详情 DTO。</returns>
|
|
||||||
public static BillingDetailDto ToBillingDetailDto(
|
|
||||||
this TenantBillingStatement billing,
|
|
||||||
List<TenantPayment> payments,
|
|
||||||
string? tenantName = null)
|
|
||||||
{
|
|
||||||
// 反序列化账单明细
|
|
||||||
var lineItems = new List<BillingLineItemDto>();
|
|
||||||
if (!string.IsNullOrWhiteSpace(billing.LineItemsJson))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(billing.LineItemsJson) ?? [];
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
lineItems = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new BillingDetailDto
|
|
||||||
{
|
|
||||||
Id = billing.Id,
|
|
||||||
TenantId = billing.TenantId,
|
|
||||||
TenantName = tenantName ?? string.Empty,
|
|
||||||
SubscriptionId = billing.SubscriptionId,
|
|
||||||
StatementNo = billing.StatementNo,
|
|
||||||
BillingType = billing.BillingType,
|
|
||||||
Status = billing.Status,
|
|
||||||
PeriodStart = billing.PeriodStart,
|
|
||||||
PeriodEnd = billing.PeriodEnd,
|
|
||||||
AmountDue = billing.AmountDue,
|
|
||||||
AmountPaid = billing.AmountPaid,
|
|
||||||
DiscountAmount = billing.DiscountAmount,
|
|
||||||
TaxAmount = billing.TaxAmount,
|
|
||||||
TotalAmount = billing.CalculateTotalAmount(),
|
|
||||||
Currency = billing.Currency,
|
|
||||||
DueDate = billing.DueDate,
|
|
||||||
ReminderSentAt = billing.ReminderSentAt,
|
|
||||||
OverdueNotifiedAt = billing.OverdueNotifiedAt,
|
|
||||||
LineItemsJson = billing.LineItemsJson,
|
|
||||||
LineItems = lineItems,
|
|
||||||
Payments = payments.Select(p => p.ToPaymentRecordDto()).ToList(),
|
|
||||||
Notes = billing.Notes,
|
|
||||||
CreatedAt = billing.CreatedAt,
|
|
||||||
CreatedBy = billing.CreatedBy,
|
|
||||||
UpdatedAt = billing.UpdatedAt,
|
|
||||||
UpdatedBy = billing.UpdatedBy
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将支付记录实体映射为支付 DTO(旧版)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="payment">支付记录实体。</param>
|
|
||||||
/// <returns>支付 DTO。</returns>
|
|
||||||
public static PaymentDto ToDto(this TenantPayment payment)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = payment.Id,
|
|
||||||
BillingStatementId = payment.BillingStatementId,
|
|
||||||
Amount = payment.Amount,
|
|
||||||
Method = payment.Method,
|
|
||||||
Status = payment.Status,
|
|
||||||
TransactionNo = payment.TransactionNo,
|
|
||||||
ProofUrl = payment.ProofUrl,
|
|
||||||
PaidAt = payment.PaidAt,
|
|
||||||
Notes = payment.Notes,
|
|
||||||
CreatedAt = payment.CreatedAt
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 将支付记录实体映射为支付记录 DTO(新版)。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="payment">支付记录实体。</param>
|
|
||||||
/// <returns>支付记录 DTO。</returns>
|
|
||||||
public static PaymentRecordDto ToPaymentRecordDto(this TenantPayment payment)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = payment.Id,
|
|
||||||
TenantId = payment.TenantId,
|
|
||||||
BillingId = payment.BillingStatementId,
|
|
||||||
Amount = payment.Amount,
|
|
||||||
Method = payment.Method,
|
|
||||||
Status = payment.Status,
|
|
||||||
TransactionNo = payment.TransactionNo,
|
|
||||||
ProofUrl = payment.ProofUrl,
|
|
||||||
IsVerified = payment.VerifiedAt.HasValue,
|
|
||||||
PaidAt = payment.PaidAt,
|
|
||||||
VerifiedBy = payment.VerifiedBy,
|
|
||||||
VerifiedAt = payment.VerifiedAt,
|
|
||||||
RefundReason = payment.RefundReason,
|
|
||||||
RefundedAt = payment.RefundedAt,
|
|
||||||
Notes = payment.Notes,
|
|
||||||
CreatedAt = payment.CreatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量更新账单状态命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BatchUpdateStatusCommand : IRequest<int>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID 列表(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long[] BillingIds { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 新状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus NewStatus { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量操作备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 取消账单命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CancelBillingCommand : IRequest<Unit>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 取消原因。
|
|
||||||
/// </summary>
|
|
||||||
public string Reason { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 一键确认收款命令(记录支付 + 立即审核通过)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ConfirmPaymentCommand : IRequest<PaymentRecordDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付方式。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentMethod Method { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 交易号。
|
|
||||||
/// </summary>
|
|
||||||
public string? TransactionNo { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付凭证 URL。
|
|
||||||
/// </summary>
|
|
||||||
public string? ProofUrl { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建账单命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CreateBillCommand : IRequest<BillDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建账单命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CreateBillingCommand : IRequest<BillingDetailDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细列表。
|
|
||||||
/// </summary>
|
|
||||||
public List<BillingLineItemDto> LineItems { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 生成订阅账单命令(自动化场景)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GenerateSubscriptionBillingCommand : IRequest<BillingDetailDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long SubscriptionId { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 处理逾期账单命令(后台任务场景)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ProcessOverdueBillingsCommand : IRequest<int>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 记录支付命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record RecordPaymentCommand : IRequest<PaymentRecordDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付方式。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentMethod Method { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 交易号。
|
|
||||||
/// </summary>
|
|
||||||
public string? TransactionNo { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付凭证 URL。
|
|
||||||
/// </summary>
|
|
||||||
public string? ProofUrl { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新账单状态命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record UpdateBillStatusCommand : IRequest<BillDto?>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 新状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新账单状态命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record UpdateBillingStatusCommand : IRequest<Unit>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 新状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus NewStatus { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核支付命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record VerifyPaymentCommand : IRequest<PaymentRecordDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public long PaymentId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否通过审核。
|
|
||||||
/// </summary>
|
|
||||||
public bool Approved { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核备注(可选)。
|
|
||||||
/// </summary>
|
|
||||||
[MaxLength(512)]
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单详情 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillDetailDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string? TenantName { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细 JSON。
|
|
||||||
/// </summary>
|
|
||||||
public string? LineItemsJson { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录列表。
|
|
||||||
/// </summary>
|
|
||||||
public List<PaymentDto> Payments { get; init; } = new();
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string? TenantName { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单详情 DTO(管理员端)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingDetailDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal DiscountAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 税费金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TaxAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 币种。
|
|
||||||
/// </summary>
|
|
||||||
public string Currency { get; init; } = "CNY";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅 ID(可选)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细 JSON(原始字符串)。
|
|
||||||
/// </summary>
|
|
||||||
public string? LineItemsJson { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细行项目。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提醒发送时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? ReminderSentAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 逾期通知时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? OverdueNotifiedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建人 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? CreatedBy { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新人 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? UpdatedBy { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto.Legacy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单列表 DTO(用于列表展示)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingListDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <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? SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal DiscountAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 税费金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TaxAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 币种。
|
|
||||||
/// </summary>
|
|
||||||
public string Currency { get; init; } = "CNY";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否已逾期(根据到期日与状态综合判断)。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsOverdue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 逾期天数(未逾期为 0)。
|
|
||||||
/// </summary>
|
|
||||||
public int OverdueDays { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单详情 DTO(含明细项)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingDetailDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 关联订阅 ID(仅订阅/续费账单可能有值)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal DiscountAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 税费金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TaxAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 币种。
|
|
||||||
/// </summary>
|
|
||||||
public string Currency { get; init; } = "CNY";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细 JSON。
|
|
||||||
/// </summary>
|
|
||||||
public string? LineItemsJson { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细列表(从 JSON 反序列化)。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录列表。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<PaymentRecordDto> Payments { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细项 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingLineItemDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 明细类型(如:套餐费、配额包费用、其他费用)。
|
|
||||||
/// </summary>
|
|
||||||
public string ItemType { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 描述。
|
|
||||||
/// </summary>
|
|
||||||
public string Description { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数量。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Quantity { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 单价。
|
|
||||||
/// </summary>
|
|
||||||
public decimal UnitPrice { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 金额(数量 × 单价)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣率(0-1)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal? DiscountRate { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record PaymentRecordDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付方式。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentMethod Method { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付流水号。
|
|
||||||
/// </summary>
|
|
||||||
public string? TransactionNo { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付凭证 URL。
|
|
||||||
/// </summary>
|
|
||||||
public string? ProofUrl { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核状态(待审核/已通过/已拒绝)。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsVerified { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核人 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? VerifiedBy { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? VerifiedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 退款原因。
|
|
||||||
/// </summary>
|
|
||||||
public string? RefundReason { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 退款时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? RefundedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? PaidAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单统计 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingStatisticsDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(为空表示跨租户统计)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统计周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime StartDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统计周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime EndDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分组方式(Day/Week/Month)。
|
|
||||||
/// </summary>
|
|
||||||
public string GroupBy { get; init; } = "Day";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int TotalCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 待付款账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int PendingCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已付款账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int PaidCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 逾期账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int OverdueCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已取消账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int CancelledCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总应收金额(账单原始应付)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总实收金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总未收金额(总金额 - 实收)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmountUnpaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 逾期未收金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalOverdueAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分组统计:应收金额趋势(Key 为分组起始日期 yyyy-MM-dd)。
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, decimal> AmountDueTrend { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分组统计:实收金额趋势(Key 为分组起始日期 yyyy-MM-dd)。
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, decimal> AmountPaidTrend { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分组统计:账单数量趋势(Key 为分组起始日期 yyyy-MM-dd)。
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, int> CountTrend { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单导出 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingExportDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 关联订阅 ID(仅订阅/续费账单可能有值)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal DiscountAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 税费金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TaxAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 币种。
|
|
||||||
/// </summary>
|
|
||||||
public string Currency { get; init; } = "CNY";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细列表。
|
|
||||||
/// </summary>
|
|
||||||
public List<BillingLineItemDto> LineItems { get; init; } = [];
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单导出 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingExportDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅 ID(可选)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal DiscountAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 税费金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TaxAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 币种。
|
|
||||||
/// </summary>
|
|
||||||
public string Currency { get; init; } = "CNY";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<BillingLineItemDto> LineItems { get; init; } = [];
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单明细行项目 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingLineItemDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 明细类型(如:订阅费、配额包费用、其他费用)。
|
|
||||||
/// </summary>
|
|
||||||
public string ItemType { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 描述。
|
|
||||||
/// </summary>
|
|
||||||
public string Description { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数量。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Quantity { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 单价。
|
|
||||||
/// </summary>
|
|
||||||
public decimal UnitPrice { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 金额(数量 × 单价)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣率(0-1,可选)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal? DiscountRate { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单列表 DTO(管理员端列表展示)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingListDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <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? SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单编号。
|
|
||||||
/// </summary>
|
|
||||||
public string StatementNo { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodStart { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 计费周期结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PeriodEnd { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 折扣金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal DiscountAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 税费金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TaxAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总金额(应付金额 - 折扣 + 税费)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 币种。
|
|
||||||
/// </summary>
|
|
||||||
public string Currency { get; init; } = "CNY";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期日。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime DueDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否逾期。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsOverdue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 逾期天数(未逾期为 0)。
|
|
||||||
/// </summary>
|
|
||||||
public int OverdueDays { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单统计数据 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingStatisticsDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(可选,管理员可跨租户统计)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统计开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime StartDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统计结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime EndDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分组方式(Day/Week/Month)。
|
|
||||||
/// </summary>
|
|
||||||
public string GroupBy { get; init; } = "Day";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int TotalCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 待支付账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int PendingCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已支付账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int PaidCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 逾期账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int OverdueCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已取消账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int CancelledCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 总应收金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已收金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmountPaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 未收金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalAmountUnpaid { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 逾期金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalOverdueAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应收金额趋势(Key 为日期桶字符串)。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyDictionary<string, decimal> AmountDueTrend { get; init; } = new Dictionary<string, decimal>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 实收金额趋势(Key 为日期桶字符串)。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyDictionary<string, decimal> AmountPaidTrend { get; init; } = new Dictionary<string, decimal>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 数量趋势(Key 为日期桶字符串)。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyDictionary<string, int> CountTrend { get; init; } = new Dictionary<string, int>();
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单趋势数据点 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BillingTrendPointDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 分组时间点(Day/Week/Month 对齐后的时间)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Period { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int Count { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应收金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountDue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 实收金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal AmountPaid { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record PaymentDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法,序列化为字符串)。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long BillingStatementId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付方式。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentMethod Method { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 交易号。
|
|
||||||
/// </summary>
|
|
||||||
public string? TransactionNo { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付凭证 URL。
|
|
||||||
/// </summary>
|
|
||||||
public string? ProofUrl { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? PaidAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录 DTO(管理员端)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record PaymentRecordDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 支付记录 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 关联的账单 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付方式。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentMethod Method { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付状态。
|
|
||||||
/// </summary>
|
|
||||||
public TenantPaymentStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 交易号。
|
|
||||||
/// </summary>
|
|
||||||
public string? TransactionNo { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付凭证 URL。
|
|
||||||
/// </summary>
|
|
||||||
public string? ProofUrl { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 支付时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? PaidAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否已审核。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsVerified { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核人 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
|
||||||
public long? VerifiedBy { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? VerifiedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 退款原因。
|
|
||||||
/// </summary>
|
|
||||||
public string? RefundReason { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 退款时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? RefundedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量更新账单状态处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BatchUpdateStatusCommandHandler(
|
|
||||||
ITenantBillingRepository billingRepository)
|
|
||||||
: IRequestHandler<BatchUpdateStatusCommand, int>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理批量更新账单状态请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">批量更新状态命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>成功更新的账单数量。</returns>
|
|
||||||
public async Task<int> Handle(BatchUpdateStatusCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 参数验证
|
|
||||||
if (request.BillingIds.Length == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查询所有账单
|
|
||||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
|
||||||
if (billings.Count == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 批量更新状态
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var updatedCount = 0;
|
|
||||||
foreach (var billing in billings)
|
|
||||||
{
|
|
||||||
// 业务规则检查:某些状态转换可能不允许
|
|
||||||
if (CanTransitionStatus(billing.Status, request.NewStatus))
|
|
||||||
{
|
|
||||||
billing.Status = request.NewStatus;
|
|
||||||
billing.UpdatedAt = now;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
|
||||||
{
|
|
||||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
|
||||||
? $"[批量操作] {request.Notes}"
|
|
||||||
: $"{billing.Notes}\n[批量操作] {request.Notes}";
|
|
||||||
}
|
|
||||||
|
|
||||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
|
||||||
updatedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 持久化变更
|
|
||||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return updatedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 检查状态转换是否允许。
|
|
||||||
/// </summary>
|
|
||||||
private static bool CanTransitionStatus(
|
|
||||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus currentStatus,
|
|
||||||
TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus newStatus)
|
|
||||||
{
|
|
||||||
// 已支付的账单不能改为其他状态
|
|
||||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid
|
|
||||||
&& newStatus != TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Paid)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已取消的账单不能改为其他状态
|
|
||||||
if (currentStatus == TakeoutSaaS.Domain.Tenants.Enums.TenantBillingStatus.Cancelled)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 取消账单命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CancelBillingCommandHandler(
|
|
||||||
ITenantBillingRepository billingRepository)
|
|
||||||
: IRequestHandler<CancelBillingCommand, Unit>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<Unit> Handle(CancelBillingCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单
|
|
||||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
|
||||||
if (billing is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 取消账单(领域规则校验在实体方法内)
|
|
||||||
billing.Cancel(request.Reason);
|
|
||||||
|
|
||||||
// 3. 持久化
|
|
||||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
|
||||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Unit.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
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.Ids;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 一键确认收款处理器(记录支付 + 立即审核通过 + 同步更新账单已收金额/状态)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ConfirmPaymentCommandHandler(
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
ITenantPaymentRepository paymentRepository,
|
|
||||||
IIdGenerator idGenerator,
|
|
||||||
ICurrentUserAccessor currentUserAccessor)
|
|
||||||
: IRequestHandler<ConfirmPaymentCommand, PaymentRecordDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<PaymentRecordDto> Handle(ConfirmPaymentCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验操作者身份(用于写入 VerifiedBy)
|
|
||||||
if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查询账单
|
|
||||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
|
||||||
if (billing is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 业务规则检查
|
|
||||||
if (billing.Status == TenantBillingStatus.Paid)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 金额边界:不允许超过剩余应收(与前端校验保持一致)
|
|
||||||
var totalAmount = billing.CalculateTotalAmount();
|
|
||||||
var remainingAmount = totalAmount - billing.AmountPaid;
|
|
||||||
if (request.Amount > remainingAmount)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BadRequest, "支付金额不能超过剩余应收");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 幂等校验:交易号唯一
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.TransactionNo))
|
|
||||||
{
|
|
||||||
var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken);
|
|
||||||
if (exists is not null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 构建支付记录并立即审核通过
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var payment = new TenantPayment
|
|
||||||
{
|
|
||||||
Id = idGenerator.NextId(),
|
|
||||||
TenantId = billing.TenantId,
|
|
||||||
BillingStatementId = request.BillingId,
|
|
||||||
Amount = request.Amount,
|
|
||||||
Method = request.Method,
|
|
||||||
Status = TenantPaymentStatus.Pending,
|
|
||||||
TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
|
|
||||||
ProofUrl = request.ProofUrl,
|
|
||||||
PaidAt = now,
|
|
||||||
Notes = request.Notes
|
|
||||||
};
|
|
||||||
|
|
||||||
payment.Verify(currentUserAccessor.UserId);
|
|
||||||
|
|
||||||
// 7. 同步更新账单已收金额/状态(支持分次收款)
|
|
||||||
billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty);
|
|
||||||
|
|
||||||
// 8. 持久化变更(同一 DbContext 下单次 SaveChanges 可提交两张表)
|
|
||||||
await paymentRepository.AddAsync(payment, cancellationToken);
|
|
||||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
|
||||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 9. 返回 DTO
|
|
||||||
return payment.ToPaymentRecordDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
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.Ids;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建账单处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateBillCommandHandler(
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
ITenantRepository tenantRepository,
|
|
||||||
IIdGenerator idGenerator)
|
|
||||||
: IRequestHandler<CreateBillCommand, BillDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理创建账单请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">创建命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>账单 DTO。</returns>
|
|
||||||
public async Task<BillDto> Handle(CreateBillCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 验证租户存在
|
|
||||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
|
|
||||||
if (tenant is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 生成账单编号
|
|
||||||
var statementNo = $"BILL-{DateTime.UtcNow:yyyyMMdd}-{idGenerator.NextId()}";
|
|
||||||
|
|
||||||
// 3. 构建账单实体
|
|
||||||
var bill = new TenantBillingStatement
|
|
||||||
{
|
|
||||||
TenantId = request.TenantId,
|
|
||||||
StatementNo = statementNo,
|
|
||||||
PeriodStart = DateTime.UtcNow,
|
|
||||||
PeriodEnd = DateTime.UtcNow,
|
|
||||||
AmountDue = request.AmountDue,
|
|
||||||
AmountPaid = 0,
|
|
||||||
Status = TenantBillingStatus.Pending,
|
|
||||||
DueDate = request.DueDate,
|
|
||||||
LineItemsJson = request.Notes
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. 持久化账单
|
|
||||||
await billingRepository.AddAsync(bill, cancellationToken);
|
|
||||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 5. 返回 DTO
|
|
||||||
return bill.ToDto(tenant.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.Text.Json;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
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.Ids;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建账单命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateBillingCommandHandler(
|
|
||||||
ITenantRepository tenantRepository,
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
IIdGenerator idGenerator)
|
|
||||||
: IRequestHandler<CreateBillingCommand, BillingDetailDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<BillingDetailDto> Handle(CreateBillingCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验租户存在
|
|
||||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken);
|
|
||||||
if (tenant is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 构建账单实体
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
|
||||||
var lineItemsJson = JsonSerializer.Serialize(request.LineItems);
|
|
||||||
|
|
||||||
var billing = new TenantBillingStatement
|
|
||||||
{
|
|
||||||
TenantId = request.TenantId,
|
|
||||||
StatementNo = statementNo,
|
|
||||||
BillingType = request.BillingType,
|
|
||||||
SubscriptionId = null,
|
|
||||||
PeriodStart = now,
|
|
||||||
PeriodEnd = now,
|
|
||||||
AmountDue = request.AmountDue,
|
|
||||||
DiscountAmount = 0m,
|
|
||||||
TaxAmount = 0m,
|
|
||||||
AmountPaid = 0m,
|
|
||||||
Currency = "CNY",
|
|
||||||
Status = TenantBillingStatus.Pending,
|
|
||||||
DueDate = request.DueDate,
|
|
||||||
LineItemsJson = lineItemsJson,
|
|
||||||
Notes = request.Notes
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. 持久化账单
|
|
||||||
await billingRepository.AddAsync(billing, cancellationToken);
|
|
||||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 4. 返回详情 DTO
|
|
||||||
return billing.ToBillingDetailDto([], tenant.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Services;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 导出账单处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ExportBillingsQueryHandler(
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
IBillingExportService exportService)
|
|
||||||
: IRequestHandler<ExportBillingsQuery, byte[]>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<byte[]> Handle(ExportBillingsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 参数验证
|
|
||||||
if (request.BillingIds.Length == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BadRequest, "账单 ID 列表不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查询账单数据
|
|
||||||
var billings = await billingRepository.GetByIdsAsync(request.BillingIds, cancellationToken);
|
|
||||||
if (billings.Count == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 根据格式导出
|
|
||||||
var format = (request.Format ?? string.Empty).Trim().ToLowerInvariant();
|
|
||||||
return format switch
|
|
||||||
{
|
|
||||||
"excel" or "xlsx" => await exportService.ExportToExcelAsync(billings, cancellationToken),
|
|
||||||
"pdf" => await exportService.ExportToPdfAsync(billings, cancellationToken),
|
|
||||||
"csv" => await exportService.ExportToCsvAsync(billings, cancellationToken),
|
|
||||||
_ => throw new BusinessException(ErrorCodes.BadRequest, $"不支持的导出格式: {request.Format}")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.Text.Json;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
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.Ids;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 生成订阅账单命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GenerateSubscriptionBillingCommandHandler(
|
|
||||||
ISubscriptionRepository subscriptionRepository,
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
IIdGenerator idGenerator)
|
|
||||||
: IRequestHandler<GenerateSubscriptionBillingCommand, BillingDetailDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<BillingDetailDto> Handle(GenerateSubscriptionBillingCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询订阅详情(含租户/套餐信息)
|
|
||||||
var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken);
|
|
||||||
if (detail is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "订阅不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 校验套餐价格信息
|
|
||||||
var subscription = detail.Subscription;
|
|
||||||
var package = detail.Package;
|
|
||||||
if (package is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "订阅未关联有效套餐,无法生成账单");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 按订阅周期选择价格(简化规则:优先按年/按月)
|
|
||||||
var billingPeriodDays = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
|
|
||||||
var amountDue = billingPeriodDays >= 300
|
|
||||||
? package.YearlyPrice
|
|
||||||
: package.MonthlyPrice;
|
|
||||||
|
|
||||||
if (!amountDue.HasValue)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "套餐价格未配置,无法生成账单");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 幂等校验:同一周期开始时间仅允许存在一张未取消账单
|
|
||||||
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(subscription.TenantId, subscription.EffectiveFrom, cancellationToken);
|
|
||||||
if (exists)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.Conflict, "该订阅周期的账单已存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 构建账单实体
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var statementNo = $"BIL-{now:yyyyMMdd}-{idGenerator.NextId()}";
|
|
||||||
var lineItems = new List<BillingLineItemDto>
|
|
||||||
{
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
ItemType = "Subscription",
|
|
||||||
Description = $"套餐 {package.Name} 订阅费用",
|
|
||||||
Quantity = 1,
|
|
||||||
UnitPrice = amountDue.Value,
|
|
||||||
Amount = amountDue.Value,
|
|
||||||
DiscountRate = null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var billing = new TenantBillingStatement
|
|
||||||
{
|
|
||||||
TenantId = subscription.TenantId,
|
|
||||||
StatementNo = statementNo,
|
|
||||||
BillingType = BillingType.Subscription,
|
|
||||||
SubscriptionId = subscription.Id,
|
|
||||||
PeriodStart = subscription.EffectiveFrom,
|
|
||||||
PeriodEnd = subscription.EffectiveTo,
|
|
||||||
AmountDue = amountDue.Value,
|
|
||||||
DiscountAmount = 0m,
|
|
||||||
TaxAmount = 0m,
|
|
||||||
AmountPaid = 0m,
|
|
||||||
Currency = "CNY",
|
|
||||||
Status = TenantBillingStatus.Pending,
|
|
||||||
DueDate = now.AddDays(7),
|
|
||||||
LineItemsJson = JsonSerializer.Serialize(lineItems),
|
|
||||||
Notes = subscription.Notes
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. 持久化账单
|
|
||||||
await billingRepository.AddAsync(billing, cancellationToken);
|
|
||||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 7. 返回详情 DTO
|
|
||||||
return billing.ToBillingDetailDto([], detail.TenantName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取账单详情查询处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetBillDetailQueryHandler(
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
ITenantPaymentRepository paymentRepository,
|
|
||||||
ITenantRepository tenantRepository)
|
|
||||||
: IRequestHandler<GetBillDetailQuery, BillDetailDto?>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理获取账单详情请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询请求。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>账单详情或 null。</returns>
|
|
||||||
public async Task<BillDetailDto?> Handle(GetBillDetailQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单
|
|
||||||
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
|
|
||||||
if (bill is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查询支付记录
|
|
||||||
var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
|
|
||||||
|
|
||||||
// 3. 查询租户名称
|
|
||||||
var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
|
|
||||||
|
|
||||||
// 4. 返回详情 DTO
|
|
||||||
return bill.ToDetailDto(payments.ToList(), tenant?.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取账单列表查询处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetBillListQueryHandler(
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
ITenantRepository tenantRepository)
|
|
||||||
: IRequestHandler<GetBillListQuery, PagedResult<BillDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理获取账单列表请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询请求。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>分页账单列表。</returns>
|
|
||||||
public async Task<PagedResult<BillDto>> Handle(GetBillListQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 分页查询账单
|
|
||||||
var (bills, total) = await billingRepository.SearchPagedAsync(
|
|
||||||
request.TenantId,
|
|
||||||
request.Status,
|
|
||||||
request.StartDate,
|
|
||||||
request.EndDate,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
request.Keyword,
|
|
||||||
request.PageNumber,
|
|
||||||
request.PageSize,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// 2. 无数据直接返回
|
|
||||||
if (bills.Count == 0)
|
|
||||||
{
|
|
||||||
return new PagedResult<BillDto>([], request.PageNumber, request.PageSize, total);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 批量查询租户信息
|
|
||||||
var tenantIds = bills.Select(b => b.TenantId).Distinct().ToArray();
|
|
||||||
var tenants = await tenantRepository.FindByIdsAsync(tenantIds, cancellationToken);
|
|
||||||
var tenantDict = tenants.ToDictionary(t => t.Id, t => t.Name);
|
|
||||||
|
|
||||||
// 4. 映射 DTO
|
|
||||||
var result = bills.Select(b => b.ToDto(tenantDict.GetValueOrDefault(b.TenantId))).ToList();
|
|
||||||
|
|
||||||
// 5. 返回分页结果
|
|
||||||
return new PagedResult<BillDto>(result, request.PageNumber, request.PageSize, total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.Data;
|
|
||||||
using System.Data.Common;
|
|
||||||
using System.Text.Json;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询账单详情处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetBillingDetailQueryHandler(
|
|
||||||
IDapperExecutor dapperExecutor)
|
|
||||||
: IRequestHandler<GetBillingDetailQuery, BillingDetailDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理查询账单详情请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>账单详情 DTO。</returns>
|
|
||||||
public async Task<BillingDetailDto> Handle(GetBillingDetailQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单 + 支付记录(同一连接,避免多次往返)
|
|
||||||
return await dapperExecutor.QueryAsync(
|
|
||||||
DatabaseConstants.AppDataSource,
|
|
||||||
DatabaseConnectionRole.Read,
|
|
||||||
async (connection, token) =>
|
|
||||||
{
|
|
||||||
// 1.1 查询账单
|
|
||||||
await using var billCommand = CreateCommand(
|
|
||||||
connection,
|
|
||||||
BuildBillingSql(),
|
|
||||||
[
|
|
||||||
("billingId", request.BillingId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var billReader = await billCommand.ExecuteReaderAsync(token);
|
|
||||||
if (!await billReader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1.2 读取账单行数据到内存(释放 Reader,避免同连接并发执行命令)
|
|
||||||
var billingId = billReader.GetInt64(0);
|
|
||||||
var tenantId = billReader.GetInt64(1);
|
|
||||||
var tenantName = billReader.IsDBNull(2) ? string.Empty : billReader.GetString(2);
|
|
||||||
long? subscriptionId = billReader.IsDBNull(3) ? null : billReader.GetInt64(3);
|
|
||||||
var statementNo = billReader.GetString(4);
|
|
||||||
var billingType = (BillingType)billReader.GetInt32(5);
|
|
||||||
var status = (TenantBillingStatus)billReader.GetInt32(6);
|
|
||||||
var periodStart = billReader.GetDateTime(7);
|
|
||||||
var periodEnd = billReader.GetDateTime(8);
|
|
||||||
var amountDue = billReader.GetDecimal(9);
|
|
||||||
var discountAmount = billReader.GetDecimal(10);
|
|
||||||
var taxAmount = billReader.GetDecimal(11);
|
|
||||||
var amountPaid = billReader.GetDecimal(12);
|
|
||||||
var currency = billReader.IsDBNull(13) ? "CNY" : billReader.GetString(13);
|
|
||||||
var dueDate = billReader.GetDateTime(14);
|
|
||||||
DateTime? reminderSentAt = billReader.IsDBNull(15) ? null : billReader.GetDateTime(15);
|
|
||||||
DateTime? overdueNotifiedAt = billReader.IsDBNull(16) ? null : billReader.GetDateTime(16);
|
|
||||||
var notes = billReader.IsDBNull(17) ? null : billReader.GetString(17);
|
|
||||||
var lineItemsJson = billReader.IsDBNull(18) ? null : billReader.GetString(18);
|
|
||||||
var createdAt = billReader.GetDateTime(19);
|
|
||||||
long? createdBy = billReader.IsDBNull(20) ? null : billReader.GetInt64(20);
|
|
||||||
DateTime? updatedAt = billReader.IsDBNull(21) ? null : billReader.GetDateTime(21);
|
|
||||||
long? updatedBy = billReader.IsDBNull(22) ? null : billReader.GetInt64(22);
|
|
||||||
|
|
||||||
// 1.3 主动释放账单 Reader,确保后续查询不会触发 Npgsql 并发命令异常
|
|
||||||
await billReader.DisposeAsync();
|
|
||||||
|
|
||||||
// 1.4 反序列化账单明细
|
|
||||||
var lineItems = new List<BillingLineItemDto>();
|
|
||||||
if (!string.IsNullOrWhiteSpace(lineItemsJson))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lineItems = JsonSerializer.Deserialize<List<BillingLineItemDto>>(lineItemsJson) ?? [];
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
lineItems = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1.5 查询支付记录
|
|
||||||
var payments = new List<PaymentRecordDto>();
|
|
||||||
await using var paymentCommand = CreateCommand(
|
|
||||||
connection,
|
|
||||||
BuildPaymentsSql(),
|
|
||||||
[
|
|
||||||
("billingId", request.BillingId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var paymentReader = await paymentCommand.ExecuteReaderAsync(token);
|
|
||||||
while (await paymentReader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
payments.Add(new PaymentRecordDto
|
|
||||||
{
|
|
||||||
Id = paymentReader.GetInt64(0),
|
|
||||||
TenantId = paymentReader.GetInt64(1),
|
|
||||||
BillingId = paymentReader.GetInt64(2),
|
|
||||||
Amount = paymentReader.GetDecimal(3),
|
|
||||||
Method = (TenantPaymentMethod)paymentReader.GetInt32(4),
|
|
||||||
Status = (TenantPaymentStatus)paymentReader.GetInt32(5),
|
|
||||||
TransactionNo = paymentReader.IsDBNull(6) ? null : paymentReader.GetString(6),
|
|
||||||
ProofUrl = paymentReader.IsDBNull(7) ? null : paymentReader.GetString(7),
|
|
||||||
Notes = paymentReader.IsDBNull(8) ? null : paymentReader.GetString(8),
|
|
||||||
VerifiedBy = paymentReader.IsDBNull(9) ? null : paymentReader.GetInt64(9),
|
|
||||||
VerifiedAt = paymentReader.IsDBNull(10) ? null : paymentReader.GetDateTime(10),
|
|
||||||
RefundReason = paymentReader.IsDBNull(11) ? null : paymentReader.GetString(11),
|
|
||||||
RefundedAt = paymentReader.IsDBNull(12) ? null : paymentReader.GetDateTime(12),
|
|
||||||
PaidAt = paymentReader.IsDBNull(13) ? null : paymentReader.GetDateTime(13),
|
|
||||||
IsVerified = !paymentReader.IsDBNull(10),
|
|
||||||
CreatedAt = paymentReader.GetDateTime(14)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1.6 组装详情 DTO
|
|
||||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
|
||||||
|
|
||||||
return new BillingDetailDto
|
|
||||||
{
|
|
||||||
Id = billingId,
|
|
||||||
TenantId = tenantId,
|
|
||||||
TenantName = tenantName,
|
|
||||||
SubscriptionId = subscriptionId,
|
|
||||||
StatementNo = statementNo,
|
|
||||||
BillingType = billingType,
|
|
||||||
Status = status,
|
|
||||||
PeriodStart = periodStart,
|
|
||||||
PeriodEnd = periodEnd,
|
|
||||||
AmountDue = amountDue,
|
|
||||||
DiscountAmount = discountAmount,
|
|
||||||
TaxAmount = taxAmount,
|
|
||||||
TotalAmount = totalAmount,
|
|
||||||
AmountPaid = amountPaid,
|
|
||||||
Currency = currency,
|
|
||||||
DueDate = dueDate,
|
|
||||||
ReminderSentAt = reminderSentAt,
|
|
||||||
OverdueNotifiedAt = overdueNotifiedAt,
|
|
||||||
LineItemsJson = lineItemsJson,
|
|
||||||
LineItems = lineItems,
|
|
||||||
Payments = payments,
|
|
||||||
Notes = notes,
|
|
||||||
CreatedAt = createdAt,
|
|
||||||
CreatedBy = createdBy,
|
|
||||||
UpdatedAt = updatedAt,
|
|
||||||
UpdatedBy = updatedBy
|
|
||||||
};
|
|
||||||
},
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildBillingSql()
|
|
||||||
{
|
|
||||||
return """
|
|
||||||
select
|
|
||||||
b."Id",
|
|
||||||
b."TenantId",
|
|
||||||
t."Name" as "TenantName",
|
|
||||||
b."SubscriptionId",
|
|
||||||
b."StatementNo",
|
|
||||||
b."BillingType",
|
|
||||||
b."Status",
|
|
||||||
b."PeriodStart",
|
|
||||||
b."PeriodEnd",
|
|
||||||
b."AmountDue",
|
|
||||||
b."DiscountAmount",
|
|
||||||
b."TaxAmount",
|
|
||||||
b."AmountPaid",
|
|
||||||
b."Currency",
|
|
||||||
b."DueDate",
|
|
||||||
b."ReminderSentAt",
|
|
||||||
b."OverdueNotifiedAt",
|
|
||||||
b."Notes",
|
|
||||||
b."LineItemsJson",
|
|
||||||
b."CreatedAt",
|
|
||||||
b."CreatedBy",
|
|
||||||
b."UpdatedAt",
|
|
||||||
b."UpdatedBy"
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and b."Id" = @billingId
|
|
||||||
limit 1;
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildPaymentsSql()
|
|
||||||
{
|
|
||||||
return """
|
|
||||||
select
|
|
||||||
p."Id",
|
|
||||||
p."TenantId",
|
|
||||||
p."BillingStatementId",
|
|
||||||
p."Amount",
|
|
||||||
p."Method",
|
|
||||||
p."Status",
|
|
||||||
p."TransactionNo",
|
|
||||||
p."ProofUrl",
|
|
||||||
p."Notes",
|
|
||||||
p."VerifiedBy",
|
|
||||||
p."VerifiedAt",
|
|
||||||
p."RefundReason",
|
|
||||||
p."RefundedAt",
|
|
||||||
p."PaidAt",
|
|
||||||
p."CreatedAt"
|
|
||||||
from public.tenant_payments p
|
|
||||||
where p."DeletedAt" is null
|
|
||||||
and p."BillingStatementId" = @billingId
|
|
||||||
order by p."CreatedAt" desc;
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
|
||||||
{
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandText = sql;
|
|
||||||
|
|
||||||
foreach (var (name, value) in parameters)
|
|
||||||
{
|
|
||||||
var p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (DbCommand)command;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.Data;
|
|
||||||
using System.Data.Common;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询账单列表处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetBillingListQueryHandler(
|
|
||||||
IDapperExecutor dapperExecutor)
|
|
||||||
: IRequestHandler<GetBillingListQuery, PagedResult<BillingListDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理分页查询账单列表请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>分页账单列表 DTO。</returns>
|
|
||||||
public async Task<PagedResult<BillingListDto>> Handle(GetBillingListQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 参数规范化
|
|
||||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
|
||||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
|
||||||
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
|
|
||||||
var minAmount = request.MinAmount;
|
|
||||||
var maxAmount = request.MaxAmount;
|
|
||||||
var offset = (page - 1) * pageSize;
|
|
||||||
|
|
||||||
// 1.1 金额区间规范化(避免 min > max 导致结果为空)
|
|
||||||
if (minAmount.HasValue && maxAmount.HasValue && minAmount.Value > maxAmount.Value)
|
|
||||||
{
|
|
||||||
(minAmount, maxAmount) = (maxAmount, minAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 排序白名单(防 SQL 注入)
|
|
||||||
var orderBy = request.SortBy?.Trim() switch
|
|
||||||
{
|
|
||||||
"DueDate" => "b.\"DueDate\"",
|
|
||||||
"AmountDue" => "b.\"AmountDue\"",
|
|
||||||
"PeriodStart" => "b.\"PeriodStart\"",
|
|
||||||
"PeriodEnd" => "b.\"PeriodEnd\"",
|
|
||||||
"CreatedAt" => "b.\"CreatedAt\"",
|
|
||||||
_ => "b.\"CreatedAt\""
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. 查询总数 + 列表
|
|
||||||
return await dapperExecutor.QueryAsync(
|
|
||||||
DatabaseConstants.AppDataSource,
|
|
||||||
DatabaseConnectionRole.Read,
|
|
||||||
async (connection, token) =>
|
|
||||||
{
|
|
||||||
// 3.1 统计总数
|
|
||||||
var total = await ExecuteScalarIntAsync(
|
|
||||||
connection,
|
|
||||||
BuildCountSql(),
|
|
||||||
[
|
|
||||||
("tenantId", request.TenantId),
|
|
||||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
|
||||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
|
||||||
("startDate", request.StartDate),
|
|
||||||
("endDate", request.EndDate),
|
|
||||||
("minAmount", minAmount),
|
|
||||||
("maxAmount", maxAmount),
|
|
||||||
("keyword", keyword)
|
|
||||||
],
|
|
||||||
token);
|
|
||||||
|
|
||||||
// 3.2 查询列表
|
|
||||||
var listSql = BuildListSql(orderBy, request.SortDesc);
|
|
||||||
await using var listCommand = CreateCommand(
|
|
||||||
connection,
|
|
||||||
listSql,
|
|
||||||
[
|
|
||||||
("tenantId", request.TenantId),
|
|
||||||
("status", request.Status.HasValue ? (int)request.Status.Value : null),
|
|
||||||
("billingType", request.BillingType.HasValue ? (int)request.BillingType.Value : null),
|
|
||||||
("startDate", request.StartDate),
|
|
||||||
("endDate", request.EndDate),
|
|
||||||
("minAmount", minAmount),
|
|
||||||
("maxAmount", maxAmount),
|
|
||||||
("keyword", keyword),
|
|
||||||
("offset", offset),
|
|
||||||
("limit", pageSize)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var items = new List<BillingListDto>();
|
|
||||||
while (await reader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
var dueDate = reader.GetDateTime(13);
|
|
||||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
|
||||||
var amountDue = reader.GetDecimal(8);
|
|
||||||
var discountAmount = reader.GetDecimal(9);
|
|
||||||
var taxAmount = reader.GetDecimal(10);
|
|
||||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
|
||||||
|
|
||||||
// 3.2.1 逾期辅助字段
|
|
||||||
var isOverdue = status is TenantBillingStatus.Overdue
|
|
||||||
|| (status is TenantBillingStatus.Pending && dueDate < now);
|
|
||||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
|
||||||
|
|
||||||
items.Add(new BillingListDto
|
|
||||||
{
|
|
||||||
Id = reader.GetInt64(0),
|
|
||||||
TenantId = reader.GetInt64(1),
|
|
||||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
|
||||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
|
||||||
StatementNo = reader.GetString(4),
|
|
||||||
BillingType = (BillingType)reader.GetInt32(5),
|
|
||||||
PeriodStart = reader.GetDateTime(6),
|
|
||||||
PeriodEnd = reader.GetDateTime(7),
|
|
||||||
AmountDue = amountDue,
|
|
||||||
DiscountAmount = discountAmount,
|
|
||||||
TaxAmount = taxAmount,
|
|
||||||
TotalAmount = totalAmount,
|
|
||||||
AmountPaid = reader.GetDecimal(11),
|
|
||||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
|
||||||
Status = status,
|
|
||||||
DueDate = dueDate,
|
|
||||||
IsOverdue = isOverdue,
|
|
||||||
OverdueDays = overdueDays,
|
|
||||||
CreatedAt = reader.GetDateTime(15),
|
|
||||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.3 返回分页
|
|
||||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
|
||||||
},
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildCountSql()
|
|
||||||
{
|
|
||||||
return """
|
|
||||||
select count(*)
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
|
||||||
and (@status::int is null or b."Status" = @status)
|
|
||||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
|
||||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
|
||||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
|
||||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
|
||||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
|
||||||
and (
|
|
||||||
@keyword::text is null
|
|
||||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
|
||||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
|
||||||
);
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildListSql(string orderBy, bool sortDesc)
|
|
||||||
{
|
|
||||||
var direction = sortDesc ? "desc" : "asc";
|
|
||||||
|
|
||||||
return $"""
|
|
||||||
select
|
|
||||||
b."Id",
|
|
||||||
b."TenantId",
|
|
||||||
t."Name" as "TenantName",
|
|
||||||
b."SubscriptionId",
|
|
||||||
b."StatementNo",
|
|
||||||
b."BillingType",
|
|
||||||
b."PeriodStart",
|
|
||||||
b."PeriodEnd",
|
|
||||||
b."AmountDue",
|
|
||||||
b."DiscountAmount",
|
|
||||||
b."TaxAmount",
|
|
||||||
b."AmountPaid",
|
|
||||||
b."Status",
|
|
||||||
b."DueDate",
|
|
||||||
b."Currency",
|
|
||||||
b."CreatedAt",
|
|
||||||
b."UpdatedAt"
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
|
||||||
and (@status::int is null or b."Status" = @status)
|
|
||||||
and (@billingType::int is null or b."BillingType" = @billingType)
|
|
||||||
and (@startDate::timestamp with time zone is null or b."PeriodStart" >= @startDate)
|
|
||||||
and (@endDate::timestamp with time zone is null or b."PeriodEnd" <= @endDate)
|
|
||||||
and (@minAmount::numeric is null or b."AmountDue" >= @minAmount)
|
|
||||||
and (@maxAmount::numeric is null or b."AmountDue" <= @maxAmount)
|
|
||||||
and (
|
|
||||||
@keyword::text is null
|
|
||||||
or b."StatementNo" ilike ('%' || @keyword::text || '%')
|
|
||||||
or t."Name" ilike ('%' || @keyword::text || '%')
|
|
||||||
)
|
|
||||||
order by {orderBy} {direction}
|
|
||||||
offset @offset
|
|
||||||
limit @limit;
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> ExecuteScalarIntAsync(
|
|
||||||
IDbConnection connection,
|
|
||||||
string sql,
|
|
||||||
(string Name, object? Value)[] parameters,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using var command = CreateCommand(connection, sql, parameters);
|
|
||||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
|
||||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
|
||||||
{
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandText = sql;
|
|
||||||
|
|
||||||
foreach (var (name, value) in parameters)
|
|
||||||
{
|
|
||||||
var p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (DbCommand)command;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.Data;
|
|
||||||
using System.Data.Common;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询账单支付记录处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetBillingPaymentsQueryHandler(
|
|
||||||
IDapperExecutor dapperExecutor)
|
|
||||||
: IRequestHandler<GetBillingPaymentsQuery, List<PaymentRecordDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理查询账单支付记录请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>支付记录列表 DTO。</returns>
|
|
||||||
public async Task<List<PaymentRecordDto>> Handle(GetBillingPaymentsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验账单是否存在
|
|
||||||
return await dapperExecutor.QueryAsync(
|
|
||||||
DatabaseConstants.AppDataSource,
|
|
||||||
DatabaseConnectionRole.Read,
|
|
||||||
async (connection, token) =>
|
|
||||||
{
|
|
||||||
// 1.1 校验账单存在
|
|
||||||
var exists = await ExecuteScalarIntAsync(
|
|
||||||
connection,
|
|
||||||
"""
|
|
||||||
select 1
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and b."Id" = @billingId
|
|
||||||
limit 1;
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
("billingId", request.BillingId)
|
|
||||||
],
|
|
||||||
token);
|
|
||||||
|
|
||||||
if (exists == 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1.2 查询支付记录
|
|
||||||
await using var command = CreateCommand(
|
|
||||||
connection,
|
|
||||||
"""
|
|
||||||
select
|
|
||||||
p."Id",
|
|
||||||
p."TenantId",
|
|
||||||
p."BillingStatementId",
|
|
||||||
p."Amount",
|
|
||||||
p."Method",
|
|
||||||
p."Status",
|
|
||||||
p."TransactionNo",
|
|
||||||
p."ProofUrl",
|
|
||||||
p."Notes",
|
|
||||||
p."VerifiedBy",
|
|
||||||
p."VerifiedAt",
|
|
||||||
p."RefundReason",
|
|
||||||
p."RefundedAt",
|
|
||||||
p."PaidAt",
|
|
||||||
p."CreatedAt"
|
|
||||||
from public.tenant_payments p
|
|
||||||
where p."DeletedAt" is null
|
|
||||||
and p."BillingStatementId" = @billingId
|
|
||||||
order by p."CreatedAt" desc;
|
|
||||||
""",
|
|
||||||
[
|
|
||||||
("billingId", request.BillingId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync(token);
|
|
||||||
var results = new List<PaymentRecordDto>();
|
|
||||||
while (await reader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
results.Add(new PaymentRecordDto
|
|
||||||
{
|
|
||||||
Id = reader.GetInt64(0),
|
|
||||||
TenantId = reader.GetInt64(1),
|
|
||||||
BillingId = reader.GetInt64(2),
|
|
||||||
Amount = reader.GetDecimal(3),
|
|
||||||
Method = (TenantPaymentMethod)reader.GetInt32(4),
|
|
||||||
Status = (TenantPaymentStatus)reader.GetInt32(5),
|
|
||||||
TransactionNo = reader.IsDBNull(6) ? null : reader.GetString(6),
|
|
||||||
ProofUrl = reader.IsDBNull(7) ? null : reader.GetString(7),
|
|
||||||
Notes = reader.IsDBNull(8) ? null : reader.GetString(8),
|
|
||||||
VerifiedBy = reader.IsDBNull(9) ? null : reader.GetInt64(9),
|
|
||||||
VerifiedAt = reader.IsDBNull(10) ? null : reader.GetDateTime(10),
|
|
||||||
RefundReason = reader.IsDBNull(11) ? null : reader.GetString(11),
|
|
||||||
RefundedAt = reader.IsDBNull(12) ? null : reader.GetDateTime(12),
|
|
||||||
PaidAt = reader.IsDBNull(13) ? null : reader.GetDateTime(13),
|
|
||||||
IsVerified = !reader.IsDBNull(10),
|
|
||||||
CreatedAt = reader.GetDateTime(14)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> ExecuteScalarIntAsync(
|
|
||||||
IDbConnection connection,
|
|
||||||
string sql,
|
|
||||||
(string Name, object? Value)[] parameters,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using var command = CreateCommand(connection, sql, parameters);
|
|
||||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
|
||||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
|
||||||
{
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandText = sql;
|
|
||||||
|
|
||||||
foreach (var (name, value) in parameters)
|
|
||||||
{
|
|
||||||
var p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (DbCommand)command;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.Data;
|
|
||||||
using System.Data.Common;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询账单统计数据处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetBillingStatisticsQueryHandler(
|
|
||||||
IDapperExecutor dapperExecutor)
|
|
||||||
: IRequestHandler<GetBillingStatisticsQuery, BillingStatisticsDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理查询账单统计数据请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>账单统计数据 DTO。</returns>
|
|
||||||
public async Task<BillingStatisticsDto> Handle(GetBillingStatisticsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 参数规范化
|
|
||||||
var startDate = request.StartDate ?? DateTime.UtcNow.AddMonths(-1);
|
|
||||||
var endDate = request.EndDate ?? DateTime.UtcNow;
|
|
||||||
var groupBy = NormalizeGroupBy(request.GroupBy);
|
|
||||||
|
|
||||||
// 2. 查询统计数据(总览 + 趋势)
|
|
||||||
return await dapperExecutor.QueryAsync(
|
|
||||||
DatabaseConstants.AppDataSource,
|
|
||||||
DatabaseConnectionRole.Read,
|
|
||||||
async (connection, token) =>
|
|
||||||
{
|
|
||||||
// 2.1 总览统计
|
|
||||||
await using var summaryCommand = CreateCommand(
|
|
||||||
connection,
|
|
||||||
BuildSummarySql(),
|
|
||||||
[
|
|
||||||
("tenantId", request.TenantId),
|
|
||||||
("startDate", startDate),
|
|
||||||
("endDate", endDate),
|
|
||||||
("now", DateTime.UtcNow)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var summaryReader = await summaryCommand.ExecuteReaderAsync(token);
|
|
||||||
await summaryReader.ReadAsync(token);
|
|
||||||
|
|
||||||
var totalCount = summaryReader.IsDBNull(0) ? 0 : summaryReader.GetInt32(0);
|
|
||||||
var pendingCount = summaryReader.IsDBNull(1) ? 0 : summaryReader.GetInt32(1);
|
|
||||||
var paidCount = summaryReader.IsDBNull(2) ? 0 : summaryReader.GetInt32(2);
|
|
||||||
var overdueCount = summaryReader.IsDBNull(3) ? 0 : summaryReader.GetInt32(3);
|
|
||||||
var cancelledCount = summaryReader.IsDBNull(4) ? 0 : summaryReader.GetInt32(4);
|
|
||||||
var totalAmountDue = summaryReader.IsDBNull(5) ? 0m : summaryReader.GetDecimal(5);
|
|
||||||
var totalAmountPaid = summaryReader.IsDBNull(6) ? 0m : summaryReader.GetDecimal(6);
|
|
||||||
var totalAmountUnpaid = summaryReader.IsDBNull(7) ? 0m : summaryReader.GetDecimal(7);
|
|
||||||
var totalOverdueAmount = summaryReader.IsDBNull(8) ? 0m : summaryReader.GetDecimal(8);
|
|
||||||
|
|
||||||
// 2.2 趋势数据
|
|
||||||
await using var trendCommand = CreateCommand(
|
|
||||||
connection,
|
|
||||||
BuildTrendSql(groupBy),
|
|
||||||
[
|
|
||||||
("tenantId", request.TenantId),
|
|
||||||
("startDate", startDate),
|
|
||||||
("endDate", endDate)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var trendReader = await trendCommand.ExecuteReaderAsync(token);
|
|
||||||
var amountDueTrend = new Dictionary<string, decimal>();
|
|
||||||
var amountPaidTrend = new Dictionary<string, decimal>();
|
|
||||||
var countTrend = new Dictionary<string, int>();
|
|
||||||
while (await trendReader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
var bucket = trendReader.GetDateTime(0);
|
|
||||||
var key = bucket.ToString("yyyy-MM-dd");
|
|
||||||
|
|
||||||
amountDueTrend[key] = trendReader.IsDBNull(1) ? 0m : trendReader.GetDecimal(1);
|
|
||||||
amountPaidTrend[key] = trendReader.IsDBNull(2) ? 0m : trendReader.GetDecimal(2);
|
|
||||||
countTrend[key] = trendReader.IsDBNull(3) ? 0 : trendReader.GetInt32(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.3 组装 DTO
|
|
||||||
return new BillingStatisticsDto
|
|
||||||
{
|
|
||||||
TenantId = request.TenantId,
|
|
||||||
StartDate = startDate,
|
|
||||||
EndDate = endDate,
|
|
||||||
GroupBy = groupBy,
|
|
||||||
TotalCount = totalCount,
|
|
||||||
PendingCount = pendingCount,
|
|
||||||
PaidCount = paidCount,
|
|
||||||
OverdueCount = overdueCount,
|
|
||||||
CancelledCount = cancelledCount,
|
|
||||||
TotalAmountDue = totalAmountDue,
|
|
||||||
TotalAmountPaid = totalAmountPaid,
|
|
||||||
TotalAmountUnpaid = totalAmountUnpaid,
|
|
||||||
TotalOverdueAmount = totalOverdueAmount,
|
|
||||||
AmountDueTrend = amountDueTrend,
|
|
||||||
AmountPaidTrend = amountPaidTrend,
|
|
||||||
CountTrend = countTrend
|
|
||||||
};
|
|
||||||
},
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeGroupBy(string? groupBy)
|
|
||||||
{
|
|
||||||
return groupBy?.Trim() switch
|
|
||||||
{
|
|
||||||
"Week" => "Week",
|
|
||||||
"Month" => "Month",
|
|
||||||
_ => "Day"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildSummarySql()
|
|
||||||
{
|
|
||||||
return """
|
|
||||||
select
|
|
||||||
count(*)::int as "TotalCount",
|
|
||||||
coalesce(sum(case when b."Status" = 0 then 1 else 0 end), 0)::int as "PendingCount",
|
|
||||||
coalesce(sum(case when b."Status" = 1 then 1 else 0 end), 0)::int as "PaidCount",
|
|
||||||
coalesce(sum(case when b."Status" = 2 then 1 else 0 end), 0)::int as "OverdueCount",
|
|
||||||
coalesce(sum(case when b."Status" = 3 then 1 else 0 end), 0)::int as "CancelledCount",
|
|
||||||
coalesce(sum(b."AmountDue"), 0)::numeric as "TotalAmountDue",
|
|
||||||
coalesce(sum(b."AmountPaid"), 0)::numeric as "TotalAmountPaid",
|
|
||||||
coalesce(sum((b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"), 0)::numeric as "TotalAmountUnpaid",
|
|
||||||
coalesce(sum(
|
|
||||||
case
|
|
||||||
when b."Status" in (0, 2) and b."DueDate" < @now
|
|
||||||
then (b."AmountDue" - b."DiscountAmount" + b."TaxAmount") - b."AmountPaid"
|
|
||||||
else 0
|
|
||||||
end
|
|
||||||
), 0)::numeric as "TotalOverdueAmount"
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
|
||||||
and b."PeriodStart" >= @startDate
|
|
||||||
and b."PeriodEnd" <= @endDate;
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildTrendSql(string groupBy)
|
|
||||||
{
|
|
||||||
var dateTrunc = groupBy switch
|
|
||||||
{
|
|
||||||
"Week" => "week",
|
|
||||||
"Month" => "month",
|
|
||||||
_ => "day"
|
|
||||||
};
|
|
||||||
|
|
||||||
return $"""
|
|
||||||
select
|
|
||||||
date_trunc('{dateTrunc}', b."PeriodStart") as "Bucket",
|
|
||||||
coalesce(sum(b."AmountDue"), 0)::numeric as "AmountDue",
|
|
||||||
coalesce(sum(b."AmountPaid"), 0)::numeric as "AmountPaid",
|
|
||||||
count(*)::int as "Count"
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and (@tenantId::bigint is null or b."TenantId" = @tenantId)
|
|
||||||
and b."PeriodStart" >= @startDate
|
|
||||||
and b."PeriodEnd" <= @endDate
|
|
||||||
group by 1
|
|
||||||
order by 1 asc;
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
|
||||||
{
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandText = sql;
|
|
||||||
|
|
||||||
foreach (var (name, value) in parameters)
|
|
||||||
{
|
|
||||||
var p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (DbCommand)command;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.Data;
|
|
||||||
using System.Data.Common;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询逾期账单列表处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetOverdueBillingsQueryHandler(
|
|
||||||
IDapperExecutor dapperExecutor)
|
|
||||||
: IRequestHandler<GetOverdueBillingsQuery, PagedResult<BillingListDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理查询逾期账单列表请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>分页逾期账单列表 DTO。</returns>
|
|
||||||
public async Task<PagedResult<BillingListDto>> Handle(GetOverdueBillingsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 参数规范化
|
|
||||||
var page = request.PageNumber <= 0 ? 1 : request.PageNumber;
|
|
||||||
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
|
|
||||||
var offset = (page - 1) * pageSize;
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// 2. 查询总数 + 列表
|
|
||||||
return await dapperExecutor.QueryAsync(
|
|
||||||
DatabaseConstants.AppDataSource,
|
|
||||||
DatabaseConnectionRole.Read,
|
|
||||||
async (connection, token) =>
|
|
||||||
{
|
|
||||||
// 2.1 统计总数
|
|
||||||
var total = await ExecuteScalarIntAsync(
|
|
||||||
connection,
|
|
||||||
BuildCountSql(),
|
|
||||||
[
|
|
||||||
("now", now)
|
|
||||||
],
|
|
||||||
token);
|
|
||||||
|
|
||||||
// 2.2 查询列表
|
|
||||||
await using var listCommand = CreateCommand(
|
|
||||||
connection,
|
|
||||||
BuildListSql(),
|
|
||||||
[
|
|
||||||
("now", now),
|
|
||||||
("offset", offset),
|
|
||||||
("limit", pageSize)
|
|
||||||
]);
|
|
||||||
|
|
||||||
await using var reader = await listCommand.ExecuteReaderAsync(token);
|
|
||||||
var items = new List<BillingListDto>();
|
|
||||||
while (await reader.ReadAsync(token))
|
|
||||||
{
|
|
||||||
var dueDate = reader.GetDateTime(13);
|
|
||||||
var status = (TenantBillingStatus)reader.GetInt32(12);
|
|
||||||
var amountDue = reader.GetDecimal(8);
|
|
||||||
var discountAmount = reader.GetDecimal(9);
|
|
||||||
var taxAmount = reader.GetDecimal(10);
|
|
||||||
var totalAmount = amountDue - discountAmount + taxAmount;
|
|
||||||
var overdueDays = dueDate < now ? (int)(now - dueDate).TotalDays : 0;
|
|
||||||
|
|
||||||
items.Add(new BillingListDto
|
|
||||||
{
|
|
||||||
Id = reader.GetInt64(0),
|
|
||||||
TenantId = reader.GetInt64(1),
|
|
||||||
TenantName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
|
||||||
SubscriptionId = reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
|
||||||
StatementNo = reader.GetString(4),
|
|
||||||
BillingType = (BillingType)reader.GetInt32(5),
|
|
||||||
PeriodStart = reader.GetDateTime(6),
|
|
||||||
PeriodEnd = reader.GetDateTime(7),
|
|
||||||
AmountDue = amountDue,
|
|
||||||
DiscountAmount = discountAmount,
|
|
||||||
TaxAmount = taxAmount,
|
|
||||||
TotalAmount = totalAmount,
|
|
||||||
AmountPaid = reader.GetDecimal(11),
|
|
||||||
Status = status,
|
|
||||||
DueDate = dueDate,
|
|
||||||
Currency = reader.IsDBNull(14) ? "CNY" : reader.GetString(14),
|
|
||||||
IsOverdue = true,
|
|
||||||
OverdueDays = overdueDays,
|
|
||||||
CreatedAt = reader.GetDateTime(15),
|
|
||||||
UpdatedAt = reader.IsDBNull(16) ? null : reader.GetDateTime(16)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.3 返回分页
|
|
||||||
return new PagedResult<BillingListDto>(items, page, pageSize, total);
|
|
||||||
},
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildCountSql()
|
|
||||||
{
|
|
||||||
return """
|
|
||||||
select count(*)
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and b."DueDate" < @now
|
|
||||||
and b."Status" in (0, 2);
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildListSql()
|
|
||||||
{
|
|
||||||
return """
|
|
||||||
select
|
|
||||||
b."Id",
|
|
||||||
b."TenantId",
|
|
||||||
t."Name" as "TenantName",
|
|
||||||
b."SubscriptionId",
|
|
||||||
b."StatementNo",
|
|
||||||
b."BillingType",
|
|
||||||
b."PeriodStart",
|
|
||||||
b."PeriodEnd",
|
|
||||||
b."AmountDue",
|
|
||||||
b."DiscountAmount",
|
|
||||||
b."TaxAmount",
|
|
||||||
b."AmountPaid",
|
|
||||||
b."Status",
|
|
||||||
b."DueDate",
|
|
||||||
b."Currency",
|
|
||||||
b."CreatedAt",
|
|
||||||
b."UpdatedAt"
|
|
||||||
from public.tenant_billing_statements b
|
|
||||||
join public.tenants t on t."Id" = b."TenantId" and t."DeletedAt" is null
|
|
||||||
where b."DeletedAt" is null
|
|
||||||
and b."DueDate" < @now
|
|
||||||
and b."Status" in (0, 2)
|
|
||||||
order by b."DueDate" asc
|
|
||||||
offset @offset
|
|
||||||
limit @limit;
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> ExecuteScalarIntAsync(
|
|
||||||
IDbConnection connection,
|
|
||||||
string sql,
|
|
||||||
(string Name, object? Value)[] parameters,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await using var command = CreateCommand(connection, sql, parameters);
|
|
||||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
|
||||||
return result is null or DBNull ? 0 : Convert.ToInt32(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
|
|
||||||
{
|
|
||||||
var command = connection.CreateCommand();
|
|
||||||
command.CommandText = sql;
|
|
||||||
|
|
||||||
foreach (var (name, value) in parameters)
|
|
||||||
{
|
|
||||||
var p = command.CreateParameter();
|
|
||||||
p.ParameterName = name;
|
|
||||||
p.Value = value ?? DBNull.Value;
|
|
||||||
command.Parameters.Add(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (DbCommand)command;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取租户支付记录查询处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetTenantPaymentsQueryHandler(ITenantPaymentRepository paymentRepository)
|
|
||||||
: IRequestHandler<GetTenantPaymentsQuery, List<PaymentDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理获取支付记录请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">查询请求。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>支付记录列表。</returns>
|
|
||||||
public async Task<List<PaymentDto>> Handle(GetTenantPaymentsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询支付记录
|
|
||||||
var payments = await paymentRepository.GetByBillingIdAsync(request.BillId, cancellationToken);
|
|
||||||
|
|
||||||
// 2. 映射并返回 DTO
|
|
||||||
return payments.Select(p => p.ToDto()).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Services;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 处理逾期账单命令处理器(后台任务)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ProcessOverdueBillingsCommandHandler(
|
|
||||||
IBillingDomainService billingDomainService)
|
|
||||||
: IRequestHandler<ProcessOverdueBillingsCommand, int>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<int> Handle(ProcessOverdueBillingsCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 委托领域服务执行逾期账单处理(Pending && DueDate < Now -> Overdue)
|
|
||||||
return await billingDomainService.ProcessOverdueBillingsAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
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.Ids;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 记录支付处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class RecordPaymentCommandHandler(
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
ITenantPaymentRepository paymentRepository,
|
|
||||||
IIdGenerator idGenerator)
|
|
||||||
: IRequestHandler<RecordPaymentCommand, PaymentRecordDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理记录支付请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">记录支付命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>支付 DTO。</returns>
|
|
||||||
public async Task<PaymentRecordDto> Handle(RecordPaymentCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单
|
|
||||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
|
||||||
if (billing is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 业务规则检查
|
|
||||||
if (billing.Status == TenantBillingStatus.Paid)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许重复收款");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许收款");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 幂等校验:交易号唯一
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.TransactionNo))
|
|
||||||
{
|
|
||||||
var exists = await paymentRepository.GetByTransactionNoAsync(request.TransactionNo.Trim(), cancellationToken);
|
|
||||||
if (exists is not null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.Conflict, "交易号已存在,疑似重复提交");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 构建支付记录(默认待审核)
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var payment = new TenantPayment
|
|
||||||
{
|
|
||||||
Id = idGenerator.NextId(),
|
|
||||||
TenantId = billing.TenantId,
|
|
||||||
BillingStatementId = request.BillingId,
|
|
||||||
Amount = request.Amount,
|
|
||||||
Method = request.Method,
|
|
||||||
Status = TenantPaymentStatus.Pending,
|
|
||||||
TransactionNo = string.IsNullOrWhiteSpace(request.TransactionNo) ? null : request.TransactionNo.Trim(),
|
|
||||||
ProofUrl = request.ProofUrl,
|
|
||||||
PaidAt = now,
|
|
||||||
Notes = request.Notes
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5. 持久化变更
|
|
||||||
await paymentRepository.AddAsync(payment, cancellationToken);
|
|
||||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 6. 返回 DTO
|
|
||||||
return payment.ToPaymentRecordDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新账单状态处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UpdateBillStatusCommandHandler(
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
ITenantRepository tenantRepository)
|
|
||||||
: IRequestHandler<UpdateBillStatusCommand, BillDto?>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 处理更新账单状态请求。
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">更新命令。</param>
|
|
||||||
/// <param name="cancellationToken">取消标记。</param>
|
|
||||||
/// <returns>账单 DTO 或 null。</returns>
|
|
||||||
public async Task<BillDto?> Handle(UpdateBillStatusCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单
|
|
||||||
var bill = await billingRepository.FindByIdAsync(request.BillId, cancellationToken);
|
|
||||||
if (bill is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 更新状态
|
|
||||||
bill.Status = request.Status;
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
|
||||||
{
|
|
||||||
bill.LineItemsJson = request.Notes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 持久化变更
|
|
||||||
await billingRepository.UpdateAsync(bill, cancellationToken);
|
|
||||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 4. 查询租户名称
|
|
||||||
var tenant = await tenantRepository.FindByIdAsync(bill.TenantId, cancellationToken);
|
|
||||||
|
|
||||||
// 5. 返回 DTO
|
|
||||||
return bill.ToDto(tenant?.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新账单状态命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UpdateBillingStatusCommandHandler(
|
|
||||||
ITenantBillingRepository billingRepository)
|
|
||||||
: IRequestHandler<UpdateBillingStatusCommand, Unit>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<Unit> Handle(UpdateBillingStatusCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询账单
|
|
||||||
var billing = await billingRepository.FindByIdAsync(request.BillingId, cancellationToken);
|
|
||||||
if (billing is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "账单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 状态转换规则校验
|
|
||||||
if (billing.Status == TenantBillingStatus.Paid && request.NewStatus != TenantBillingStatus.Paid)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "已支付账单不允许改为其他状态");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (billing.Status == TenantBillingStatus.Cancelled)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.BusinessError, "已取消账单不允许变更状态");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 更新状态与备注
|
|
||||||
billing.Status = request.NewStatus;
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
|
||||||
{
|
|
||||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
|
||||||
? $"[状态变更] {request.Notes}"
|
|
||||||
: $"{billing.Notes}\n[状态变更] {request.Notes}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 持久化
|
|
||||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
|
||||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Unit.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 审核支付命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class VerifyPaymentCommandHandler(
|
|
||||||
ITenantPaymentRepository paymentRepository,
|
|
||||||
ITenantBillingRepository billingRepository,
|
|
||||||
ICurrentUserAccessor currentUserAccessor)
|
|
||||||
: IRequestHandler<VerifyPaymentCommand, PaymentRecordDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<PaymentRecordDto> Handle(VerifyPaymentCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 校验操作者身份
|
|
||||||
if (!currentUserAccessor.IsAuthenticated || currentUserAccessor.UserId <= 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.Unauthorized, "未登录或无效的操作者身份");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 查询支付记录
|
|
||||||
var payment = await paymentRepository.FindByIdAsync(request.PaymentId, cancellationToken);
|
|
||||||
if (payment is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "支付记录不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 查询关联账单
|
|
||||||
var billing = await billingRepository.FindByIdAsync(payment.BillingStatementId, cancellationToken);
|
|
||||||
if (billing is null)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.NotFound, "关联账单不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 归一化审核备注
|
|
||||||
var normalizedNotes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
|
||||||
|
|
||||||
// 5. 根据审核结果更新支付与账单状态
|
|
||||||
if (request.Approved)
|
|
||||||
{
|
|
||||||
payment.Verify(currentUserAccessor.UserId);
|
|
||||||
payment.Notes = normalizedNotes;
|
|
||||||
|
|
||||||
billing.MarkAsPaid(payment.Amount, payment.TransactionNo ?? string.Empty);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
payment.Reject(currentUserAccessor.UserId, normalizedNotes ?? string.Empty);
|
|
||||||
payment.Notes = normalizedNotes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 持久化更新状态
|
|
||||||
await paymentRepository.UpdateAsync(payment, cancellationToken);
|
|
||||||
if (request.Approved)
|
|
||||||
{
|
|
||||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 保存数据库更改
|
|
||||||
await paymentRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 8. 返回 DTO
|
|
||||||
return payment.ToPaymentRecordDto();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 导出账单(Excel/PDF/CSV)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ExportBillingsQuery : IRequest<byte[]>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 要导出的账单 ID 列表。
|
|
||||||
/// </summary>
|
|
||||||
public long[] BillingIds { get; init; } = [];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 导出格式(Excel/Pdf/Csv)。
|
|
||||||
/// </summary>
|
|
||||||
public string Format { get; init; } = "Excel";
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取账单详情查询。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetBillDetailQuery : IRequest<BillDetailDto?>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillId { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取账单列表查询。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetBillListQuery : IRequest<PagedResult<BillDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 页码(从 1 开始)。
|
|
||||||
/// </summary>
|
|
||||||
public int PageNumber { get; init; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 页大小。
|
|
||||||
/// </summary>
|
|
||||||
public int PageSize { get; init; } = 20;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID 筛选(可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 状态筛选(可选)。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus? Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 开始日期筛选(可选)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? StartDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 结束日期筛选(可选)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? EndDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 搜索关键词(账单号或租户名)。
|
|
||||||
/// </summary>
|
|
||||||
public string? Keyword { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询账单详情(含明细项)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetBillingDetailQuery : IRequest<BillingDetailDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分页查询账单列表。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetBillingListQuery : IRequest<PagedResult<BillingListDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(可选,管理员可查询所有租户)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单状态筛选。
|
|
||||||
/// </summary>
|
|
||||||
public TenantBillingStatus? Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单类型筛选。
|
|
||||||
/// </summary>
|
|
||||||
public BillingType? BillingType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单起始时间(UTC)筛选。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? StartDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单结束时间(UTC)筛选。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? EndDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 关键词搜索(账单编号)。
|
|
||||||
/// </summary>
|
|
||||||
public string? Keyword { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 最小应付金额筛选(包含)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal? MinAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 最大应付金额筛选(包含)。
|
|
||||||
/// </summary>
|
|
||||||
public decimal? MaxAmount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 页码(从 1 开始)。
|
|
||||||
/// </summary>
|
|
||||||
public int PageNumber { get; init; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 每页条数。
|
|
||||||
/// </summary>
|
|
||||||
public int PageSize { get; init; } = 20;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 排序字段(DueDate/CreatedAt/AmountDue)。
|
|
||||||
/// </summary>
|
|
||||||
public string? SortBy { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否降序排序。
|
|
||||||
/// </summary>
|
|
||||||
public bool SortDesc { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询账单的支付记录。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetBillingPaymentsQuery : IRequest<List<PaymentRecordDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillingId { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询账单统计数据。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetBillingStatisticsQuery : IRequest<BillingStatisticsDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(可选,管理员可查询所有租户)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统计开始时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? StartDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 统计结束时间(UTC)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? EndDate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 分组方式(Day/Week/Month)。
|
|
||||||
/// </summary>
|
|
||||||
public string? GroupBy { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 查询逾期账单列表。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetOverdueBillingsQuery : IRequest<PagedResult<BillingListDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 页码(从 1 开始)。
|
|
||||||
/// </summary>
|
|
||||||
public int PageNumber { get; init; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 每页条数。
|
|
||||||
/// </summary>
|
|
||||||
public int PageSize { get; init; } = 20;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取租户支付记录查询。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetTenantPaymentsQuery : IRequest<List<PaymentDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 账单 ID(雪花算法)。
|
|
||||||
/// </summary>
|
|
||||||
public long BillId { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 一键确认收款命令验证器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ConfirmPaymentCommandValidator : AbstractValidator<ConfirmPaymentCommand>
|
|
||||||
{
|
|
||||||
public ConfirmPaymentCommandValidator()
|
|
||||||
{
|
|
||||||
// 1. 账单 ID 必填
|
|
||||||
RuleFor(x => x.BillingId)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("账单 ID 必须大于 0");
|
|
||||||
|
|
||||||
// 2. 支付金额必须大于 0
|
|
||||||
RuleFor(x => x.Amount)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("支付金额必须大于 0")
|
|
||||||
.LessThanOrEqualTo(1_000_000_000)
|
|
||||||
.WithMessage("支付金额不能超过 10 亿");
|
|
||||||
|
|
||||||
// 3. 支付方式必填
|
|
||||||
RuleFor(x => x.Method)
|
|
||||||
.IsInEnum()
|
|
||||||
.WithMessage("支付方式无效");
|
|
||||||
|
|
||||||
// 4. 交易号必填
|
|
||||||
RuleFor(x => x.TransactionNo)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("交易号不能为空")
|
|
||||||
.MaximumLength(64)
|
|
||||||
.WithMessage("交易号不能超过 64 个字符");
|
|
||||||
|
|
||||||
// 5. 支付凭证 URL(可选)
|
|
||||||
RuleFor(x => x.ProofUrl)
|
|
||||||
.MaximumLength(500)
|
|
||||||
.WithMessage("支付凭证 URL 不能超过 500 个字符")
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.ProofUrl));
|
|
||||||
|
|
||||||
// 6. 备注(可选)
|
|
||||||
RuleFor(x => x.Notes)
|
|
||||||
.MaximumLength(500)
|
|
||||||
.WithMessage("备注不能超过 500 个字符")
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建账单命令验证器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateBillingCommandValidator : AbstractValidator<CreateBillingCommand>
|
|
||||||
{
|
|
||||||
public CreateBillingCommandValidator()
|
|
||||||
{
|
|
||||||
// 1. 租户 ID 必填
|
|
||||||
RuleFor(x => x.TenantId)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("租户 ID 必须大于 0");
|
|
||||||
|
|
||||||
// 2. 账单类型必填
|
|
||||||
RuleFor(x => x.BillingType)
|
|
||||||
.IsInEnum()
|
|
||||||
.WithMessage("账单类型无效");
|
|
||||||
|
|
||||||
// 3. 应付金额必须大于 0
|
|
||||||
RuleFor(x => x.AmountDue)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("应付金额必须大于 0");
|
|
||||||
|
|
||||||
// 4. 到期日必须是未来时间
|
|
||||||
RuleFor(x => x.DueDate)
|
|
||||||
.GreaterThan(DateTime.UtcNow)
|
|
||||||
.WithMessage("到期日必须是未来时间");
|
|
||||||
|
|
||||||
// 5. 账单明细至少包含一项
|
|
||||||
RuleFor(x => x.LineItems)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("账单明细不能为空");
|
|
||||||
|
|
||||||
// 6. 账单明细项验证
|
|
||||||
RuleForEach(x => x.LineItems)
|
|
||||||
.ChildRules(lineItem =>
|
|
||||||
{
|
|
||||||
lineItem.RuleFor(x => x.ItemType)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("账单明细类型不能为空")
|
|
||||||
.MaximumLength(50)
|
|
||||||
.WithMessage("账单明细类型不能超过 50 个字符");
|
|
||||||
|
|
||||||
lineItem.RuleFor(x => x.Description)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("账单明细描述不能为空")
|
|
||||||
.MaximumLength(200)
|
|
||||||
.WithMessage("账单明细描述不能超过 200 个字符");
|
|
||||||
|
|
||||||
lineItem.RuleFor(x => x.Quantity)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("账单明细数量必须大于 0");
|
|
||||||
|
|
||||||
lineItem.RuleFor(x => x.UnitPrice)
|
|
||||||
.GreaterThanOrEqualTo(0)
|
|
||||||
.WithMessage("账单明细单价不能为负数");
|
|
||||||
|
|
||||||
lineItem.RuleFor(x => x.Amount)
|
|
||||||
.GreaterThanOrEqualTo(0)
|
|
||||||
.WithMessage("账单明细金额不能为负数");
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. 备注长度限制(可选)
|
|
||||||
RuleFor(x => x.Notes)
|
|
||||||
.MaximumLength(500)
|
|
||||||
.WithMessage("备注不能超过 500 个字符")
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 记录支付命令验证器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class RecordPaymentCommandValidator : AbstractValidator<RecordPaymentCommand>
|
|
||||||
{
|
|
||||||
public RecordPaymentCommandValidator()
|
|
||||||
{
|
|
||||||
// 1. 账单 ID 必填
|
|
||||||
RuleFor(x => x.BillingId)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("账单 ID 必须大于 0");
|
|
||||||
|
|
||||||
// 2. 支付金额必须大于 0
|
|
||||||
RuleFor(x => x.Amount)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("支付金额必须大于 0")
|
|
||||||
.LessThanOrEqualTo(1_000_000_000)
|
|
||||||
.WithMessage("支付金额不能超过 10 亿");
|
|
||||||
|
|
||||||
// 3. 支付方式必填
|
|
||||||
RuleFor(x => x.Method)
|
|
||||||
.IsInEnum()
|
|
||||||
.WithMessage("支付方式无效");
|
|
||||||
|
|
||||||
// 4. 交易号必填
|
|
||||||
RuleFor(x => x.TransactionNo)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("交易号不能为空")
|
|
||||||
.MaximumLength(64)
|
|
||||||
.WithMessage("交易号不能超过 64 个字符");
|
|
||||||
|
|
||||||
// 5. 支付凭证 URL(可选)
|
|
||||||
RuleFor(x => x.ProofUrl)
|
|
||||||
.MaximumLength(500)
|
|
||||||
.WithMessage("支付凭证 URL 不能超过 500 个字符")
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.ProofUrl));
|
|
||||||
|
|
||||||
// 6. 备注(可选)
|
|
||||||
RuleFor(x => x.Notes)
|
|
||||||
.MaximumLength(500)
|
|
||||||
.WithMessage("备注不能超过 500 个字符")
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
using TakeoutSaaS.Application.App.Billings.Commands;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Billings.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新账单状态命令验证器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UpdateBillingStatusCommandValidator : AbstractValidator<UpdateBillingStatusCommand>
|
|
||||||
{
|
|
||||||
public UpdateBillingStatusCommandValidator()
|
|
||||||
{
|
|
||||||
// 1. 账单 ID 必填
|
|
||||||
RuleFor(x => x.BillingId)
|
|
||||||
.GreaterThan(0)
|
|
||||||
.WithMessage("账单 ID 必须大于 0");
|
|
||||||
|
|
||||||
// 2. 状态枚举校验
|
|
||||||
RuleFor(x => x.NewStatus)
|
|
||||||
.IsInEnum()
|
|
||||||
.WithMessage("新状态无效");
|
|
||||||
|
|
||||||
// 3. 备注长度限制(可选)
|
|
||||||
RuleFor(x => x.Notes)
|
|
||||||
.MaximumLength(500)
|
|
||||||
.WithMessage("备注不能超过 500 个字符")
|
|
||||||
.When(x => !string.IsNullOrWhiteSpace(x.Notes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建配额包命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record CreateQuotaPackageCommand : IRequest<QuotaPackageDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包名称。
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额数值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal QuotaValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 价格。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Price { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否上架。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsActive { get; init; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 排序。
|
|
||||||
/// </summary>
|
|
||||||
public int SortOrder { get; init; } = 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 描述。
|
|
||||||
/// </summary>
|
|
||||||
public string? Description { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 删除配额包命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DeleteQuotaPackageCommand : IRequest<bool>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 ID。
|
|
||||||
/// </summary>
|
|
||||||
public long QuotaPackageId { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 为租户购买配额包命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record PurchaseQuotaPackageCommand : IRequest<TenantQuotaPurchaseDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 ID。
|
|
||||||
/// </summary>
|
|
||||||
public long QuotaPackageId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 过期时间(可选)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? ExpiredAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新配额包命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record UpdateQuotaPackageCommand : IRequest<QuotaPackageDto?>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 ID。
|
|
||||||
/// </summary>
|
|
||||||
public long QuotaPackageId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包名称。
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额数值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal QuotaValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 价格。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Price { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否上架。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsActive { get; init; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 排序。
|
|
||||||
/// </summary>
|
|
||||||
public int SortOrder { get; init; } = 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 描述。
|
|
||||||
/// </summary>
|
|
||||||
public string? Description { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新配额包状态命令(上架/下架)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record UpdateQuotaPackageStatusCommand : IRequest<bool>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 ID。
|
|
||||||
/// </summary>
|
|
||||||
public long QuotaPackageId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否上架。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsActive { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record QuotaPackageDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包名称。
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额数值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal QuotaValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 价格。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Price { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否上架。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsActive { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 排序。
|
|
||||||
/// </summary>
|
|
||||||
public int SortOrder { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 描述。
|
|
||||||
/// </summary>
|
|
||||||
public string? Description { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime CreatedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? UpdatedAt { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包列表 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record QuotaPackageListDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包名称。
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额数值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal QuotaValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 价格。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Price { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否上架。
|
|
||||||
/// </summary>
|
|
||||||
public bool IsActive { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 排序。
|
|
||||||
/// </summary>
|
|
||||||
public int SortOrder { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户配额购买记录 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record TenantQuotaPurchaseDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 购买记录 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long QuotaPackageId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额包名称。
|
|
||||||
/// </summary>
|
|
||||||
public string QuotaPackageName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 购买时的配额值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal QuotaValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 购买价格。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Price { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 购买时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime PurchasedAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 过期时间(可选)。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? ExpiredAt { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注。
|
|
||||||
/// </summary>
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户配额使用情况 DTO。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record TenantQuotaUsageDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额上限。
|
|
||||||
/// </summary>
|
|
||||||
public decimal LimitValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已使用值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal UsedValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 剩余值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal RemainingValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额刷新周期。
|
|
||||||
/// </summary>
|
|
||||||
public string? ResetCycle { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 最近一次重置时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? LastResetAt { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 创建配额包命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CreateQuotaPackageCommandHandler(
|
|
||||||
IQuotaPackageRepository quotaPackageRepository,
|
|
||||||
IIdGenerator idGenerator)
|
|
||||||
: IRequestHandler<CreateQuotaPackageCommand, QuotaPackageDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<QuotaPackageDto> Handle(CreateQuotaPackageCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 创建配额包实体
|
|
||||||
var quotaPackage = new QuotaPackage
|
|
||||||
{
|
|
||||||
Id = idGenerator.NextId(),
|
|
||||||
Name = request.Name,
|
|
||||||
QuotaType = request.QuotaType,
|
|
||||||
QuotaValue = request.QuotaValue,
|
|
||||||
Price = request.Price,
|
|
||||||
IsActive = request.IsActive,
|
|
||||||
SortOrder = request.SortOrder,
|
|
||||||
Description = request.Description,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. 保存到数据库
|
|
||||||
await quotaPackageRepository.AddAsync(quotaPackage, cancellationToken);
|
|
||||||
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 3. 返回 DTO
|
|
||||||
return new QuotaPackageDto
|
|
||||||
{
|
|
||||||
Id = quotaPackage.Id,
|
|
||||||
Name = quotaPackage.Name,
|
|
||||||
QuotaType = quotaPackage.QuotaType,
|
|
||||||
QuotaValue = quotaPackage.QuotaValue,
|
|
||||||
Price = quotaPackage.Price,
|
|
||||||
IsActive = quotaPackage.IsActive,
|
|
||||||
SortOrder = quotaPackage.SortOrder,
|
|
||||||
Description = quotaPackage.Description,
|
|
||||||
CreatedAt = quotaPackage.CreatedAt,
|
|
||||||
UpdatedAt = quotaPackage.UpdatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 删除配额包命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DeleteQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
|
|
||||||
: IRequestHandler<DeleteQuotaPackageCommand, bool>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<bool> Handle(DeleteQuotaPackageCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 软删除配额包
|
|
||||||
var deleted = await quotaPackageRepository.SoftDeleteAsync(request.QuotaPackageId, cancellationToken);
|
|
||||||
|
|
||||||
if (!deleted)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 保存变更
|
|
||||||
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取配额包列表查询处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetQuotaPackageListQueryHandler(IQuotaPackageRepository quotaPackageRepository)
|
|
||||||
: IRequestHandler<GetQuotaPackageListQuery, PagedResult<QuotaPackageListDto>>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<PagedResult<QuotaPackageListDto>> Handle(GetQuotaPackageListQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 分页查询
|
|
||||||
var (items, total) = await quotaPackageRepository.SearchPagedAsync(
|
|
||||||
request.QuotaType,
|
|
||||||
request.IsActive,
|
|
||||||
request.Page,
|
|
||||||
request.PageSize,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// 2. 映射为 DTO
|
|
||||||
var dtos = items.Select(x => new QuotaPackageListDto
|
|
||||||
{
|
|
||||||
Id = x.Id,
|
|
||||||
Name = x.Name,
|
|
||||||
QuotaType = x.QuotaType,
|
|
||||||
QuotaValue = x.QuotaValue,
|
|
||||||
Price = x.Price,
|
|
||||||
IsActive = x.IsActive,
|
|
||||||
SortOrder = x.SortOrder
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
// 3. 返回分页结果
|
|
||||||
return new PagedResult<QuotaPackageListDto>(dtos, request.Page, request.PageSize, total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取租户配额购买记录查询处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetTenantQuotaPurchasesQueryHandler(IQuotaPackageRepository quotaPackageRepository)
|
|
||||||
: IRequestHandler<GetTenantQuotaPurchasesQuery, PagedResult<TenantQuotaPurchaseDto>>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<PagedResult<TenantQuotaPurchaseDto>> Handle(GetTenantQuotaPurchasesQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 分页查询购买记录
|
|
||||||
var (items, total) = await quotaPackageRepository.GetPurchasesPagedAsync(
|
|
||||||
request.TenantId,
|
|
||||||
request.Page,
|
|
||||||
request.PageSize,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// 2. 映射为 DTO
|
|
||||||
var dtos = items.Select(x => new TenantQuotaPurchaseDto
|
|
||||||
{
|
|
||||||
Id = x.Purchase.Id,
|
|
||||||
TenantId = x.Purchase.TenantId,
|
|
||||||
QuotaPackageId = x.Purchase.QuotaPackageId,
|
|
||||||
QuotaPackageName = x.Package.Name,
|
|
||||||
QuotaType = x.Package.QuotaType,
|
|
||||||
QuotaValue = x.Purchase.QuotaValue,
|
|
||||||
Price = x.Purchase.Price,
|
|
||||||
PurchasedAt = x.Purchase.PurchasedAt,
|
|
||||||
ExpiredAt = x.Purchase.ExpiredAt,
|
|
||||||
Notes = x.Purchase.Notes
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
// 3. 返回分页结果
|
|
||||||
return new PagedResult<TenantQuotaPurchaseDto>(dtos, request.Page, request.PageSize, total);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取租户配额使用情况查询处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetTenantQuotaUsageQueryHandler(IQuotaPackageRepository quotaPackageRepository)
|
|
||||||
: IRequestHandler<GetTenantQuotaUsageQuery, IReadOnlyList<TenantQuotaUsageDto>>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<TenantQuotaUsageDto>> Handle(GetTenantQuotaUsageQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查询配额使用情况
|
|
||||||
var items = await quotaPackageRepository.GetUsageByTenantAsync(
|
|
||||||
request.TenantId,
|
|
||||||
request.QuotaType,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// 2. 映射为 DTO
|
|
||||||
return items.Select(x => new TenantQuotaUsageDto
|
|
||||||
{
|
|
||||||
TenantId = x.TenantId,
|
|
||||||
QuotaType = x.QuotaType,
|
|
||||||
LimitValue = x.LimitValue,
|
|
||||||
UsedValue = x.UsedValue,
|
|
||||||
RemainingValue = x.LimitValue - x.UsedValue,
|
|
||||||
ResetCycle = x.ResetCycle,
|
|
||||||
LastResetAt = x.LastResetAt
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 购买配额包命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PurchaseQuotaPackageCommandHandler(
|
|
||||||
IQuotaPackageRepository quotaPackageRepository,
|
|
||||||
ITenantQuotaUsageHistoryRepository quotaUsageHistoryRepository,
|
|
||||||
IIdGenerator idGenerator)
|
|
||||||
: IRequestHandler<PurchaseQuotaPackageCommand, TenantQuotaPurchaseDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<TenantQuotaPurchaseDto> Handle(PurchaseQuotaPackageCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查找配额包
|
|
||||||
var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
|
|
||||||
|
|
||||||
if (quotaPackage == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("配额包不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 创建购买记录
|
|
||||||
var purchase = new TenantQuotaPackagePurchase
|
|
||||||
{
|
|
||||||
Id = idGenerator.NextId(),
|
|
||||||
TenantId = request.TenantId,
|
|
||||||
QuotaPackageId = request.QuotaPackageId,
|
|
||||||
QuotaValue = quotaPackage.QuotaValue,
|
|
||||||
Price = quotaPackage.Price,
|
|
||||||
PurchasedAt = DateTime.UtcNow,
|
|
||||||
ExpiredAt = request.ExpiredAt,
|
|
||||||
Notes = request.Notes,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. 保存购买记录
|
|
||||||
await quotaPackageRepository.AddPurchaseAsync(purchase, cancellationToken);
|
|
||||||
|
|
||||||
// 4. 更新租户配额(根据配额类型更新对应配额)
|
|
||||||
var quotaUsage = await quotaPackageRepository.FindUsageAsync(request.TenantId, quotaPackage.QuotaType, cancellationToken);
|
|
||||||
|
|
||||||
if (quotaUsage != null)
|
|
||||||
{
|
|
||||||
var beforeLimit = quotaUsage.LimitValue;
|
|
||||||
quotaUsage.LimitValue += quotaPackage.QuotaValue;
|
|
||||||
await quotaPackageRepository.UpdateUsageAsync(quotaUsage, cancellationToken);
|
|
||||||
|
|
||||||
// 4.1 记录配额变更历史(购买配额包视为“剩余增加”)
|
|
||||||
await quotaUsageHistoryRepository.AddAsync(new TenantQuotaUsageHistory
|
|
||||||
{
|
|
||||||
TenantId = request.TenantId,
|
|
||||||
QuotaType = quotaPackage.QuotaType,
|
|
||||||
UsedValue = quotaUsage.UsedValue,
|
|
||||||
LimitValue = quotaUsage.LimitValue,
|
|
||||||
RecordedAt = DateTime.UtcNow,
|
|
||||||
ChangeType = TenantQuotaUsageHistoryChangeType.Increase,
|
|
||||||
ChangeAmount = quotaUsage.LimitValue - beforeLimit,
|
|
||||||
ChangeReason = $"购买配额包:{quotaPackage.Name}"
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 5. 返回 DTO
|
|
||||||
return new TenantQuotaPurchaseDto
|
|
||||||
{
|
|
||||||
Id = purchase.Id,
|
|
||||||
TenantId = purchase.TenantId,
|
|
||||||
QuotaPackageId = purchase.QuotaPackageId,
|
|
||||||
QuotaPackageName = quotaPackage.Name,
|
|
||||||
QuotaType = quotaPackage.QuotaType,
|
|
||||||
QuotaValue = purchase.QuotaValue,
|
|
||||||
Price = purchase.Price,
|
|
||||||
PurchasedAt = purchase.PurchasedAt,
|
|
||||||
ExpiredAt = purchase.ExpiredAt,
|
|
||||||
Notes = purchase.Notes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新配额包命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UpdateQuotaPackageCommandHandler(IQuotaPackageRepository quotaPackageRepository)
|
|
||||||
: IRequestHandler<UpdateQuotaPackageCommand, QuotaPackageDto?>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<QuotaPackageDto?> Handle(UpdateQuotaPackageCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查找配额包
|
|
||||||
var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
|
|
||||||
|
|
||||||
if (quotaPackage == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 更新配额包
|
|
||||||
quotaPackage.Name = request.Name;
|
|
||||||
quotaPackage.QuotaType = request.QuotaType;
|
|
||||||
quotaPackage.QuotaValue = request.QuotaValue;
|
|
||||||
quotaPackage.Price = request.Price;
|
|
||||||
quotaPackage.IsActive = request.IsActive;
|
|
||||||
quotaPackage.SortOrder = request.SortOrder;
|
|
||||||
quotaPackage.Description = request.Description;
|
|
||||||
quotaPackage.UpdatedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// 3. 保存到数据库
|
|
||||||
await quotaPackageRepository.UpdateAsync(quotaPackage, cancellationToken);
|
|
||||||
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 4. 返回 DTO
|
|
||||||
return new QuotaPackageDto
|
|
||||||
{
|
|
||||||
Id = quotaPackage.Id,
|
|
||||||
Name = quotaPackage.Name,
|
|
||||||
QuotaType = quotaPackage.QuotaType,
|
|
||||||
QuotaValue = quotaPackage.QuotaValue,
|
|
||||||
Price = quotaPackage.Price,
|
|
||||||
IsActive = quotaPackage.IsActive,
|
|
||||||
SortOrder = quotaPackage.SortOrder,
|
|
||||||
Description = quotaPackage.Description,
|
|
||||||
CreatedAt = quotaPackage.CreatedAt,
|
|
||||||
UpdatedAt = quotaPackage.UpdatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 更新配额包状态命令处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UpdateQuotaPackageStatusCommandHandler(IQuotaPackageRepository quotaPackageRepository)
|
|
||||||
: IRequestHandler<UpdateQuotaPackageStatusCommand, bool>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<bool> Handle(UpdateQuotaPackageStatusCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 1. 查找配额包
|
|
||||||
var quotaPackage = await quotaPackageRepository.FindByIdAsync(request.QuotaPackageId, cancellationToken);
|
|
||||||
|
|
||||||
if (quotaPackage == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 更新状态
|
|
||||||
quotaPackage.IsActive = request.IsActive;
|
|
||||||
quotaPackage.UpdatedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// 3. 保存到数据库
|
|
||||||
await quotaPackageRepository.UpdateAsync(quotaPackage, cancellationToken);
|
|
||||||
await quotaPackageRepository.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取配额包列表查询。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetQuotaPackageListQuery : IRequest<PagedResult<QuotaPackageListDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型(可选筛选)。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType? QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 状态(可选筛选)。
|
|
||||||
/// </summary>
|
|
||||||
public bool? IsActive { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 页码。
|
|
||||||
/// </summary>
|
|
||||||
public int Page { get; init; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 每页大小。
|
|
||||||
/// </summary>
|
|
||||||
public int PageSize { get; init; } = 20;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取租户配额购买记录查询。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetTenantQuotaPurchasesQuery : IRequest<PagedResult<TenantQuotaPurchaseDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 页码。
|
|
||||||
/// </summary>
|
|
||||||
public int Page { get; init; } = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 每页大小。
|
|
||||||
/// </summary>
|
|
||||||
public int PageSize { get; init; } = 20;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.QuotaPackages.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取租户配额使用情况查询。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetTenantQuotaUsageQuery : IRequest<IReadOnlyList<TenantQuotaUsageDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID。
|
|
||||||
/// </summary>
|
|
||||||
public long TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型(可选筛选)。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType? QuotaType { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 即将到期的订阅项。
|
|
||||||
/// </summary>
|
|
||||||
public record ExpiringSubscriptionDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅ID。
|
|
||||||
/// </summary>
|
|
||||||
public long Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户ID。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantId { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 套餐名称。
|
|
||||||
/// </summary>
|
|
||||||
public string PackageName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅状态。
|
|
||||||
/// </summary>
|
|
||||||
public SubscriptionStatus Status { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 到期时间。
|
|
||||||
/// </summary>
|
|
||||||
public DateTime EffectiveTo { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 剩余天数。
|
|
||||||
/// </summary>
|
|
||||||
public int DaysRemaining { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否开启自动续费。
|
|
||||||
/// </summary>
|
|
||||||
public bool AutoRenew { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额使用排行。
|
|
||||||
/// </summary>
|
|
||||||
public record QuotaUsageRankingDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 排行列表。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<QuotaUsageRankItem> Rankings { get; init; } = Array.Empty<QuotaUsageRankItem>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 配额使用排行项。
|
|
||||||
/// </summary>
|
|
||||||
public record QuotaUsageRankItem
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 租户ID。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantId { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 租户名称。
|
|
||||||
/// </summary>
|
|
||||||
public string TenantName { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已使用值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal UsedValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 限制值。
|
|
||||||
/// </summary>
|
|
||||||
public decimal LimitValue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 使用百分比。
|
|
||||||
/// </summary>
|
|
||||||
public decimal UsagePercentage { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 收入统计。
|
|
||||||
/// </summary>
|
|
||||||
public record RevenueStatisticsDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 总收入。
|
|
||||||
/// </summary>
|
|
||||||
public decimal TotalRevenue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 本月收入。
|
|
||||||
/// </summary>
|
|
||||||
public decimal MonthlyRevenue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 本季度收入。
|
|
||||||
/// </summary>
|
|
||||||
public decimal QuarterlyRevenue { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 月度收入明细。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<MonthlyRevenueItem> MonthlyDetails { get; init; } = Array.Empty<MonthlyRevenueItem>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 月度收入项。
|
|
||||||
/// </summary>
|
|
||||||
public record MonthlyRevenueItem
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 年份。
|
|
||||||
/// </summary>
|
|
||||||
public int Year { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 月份。
|
|
||||||
/// </summary>
|
|
||||||
public int Month { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 收入金额。
|
|
||||||
/// </summary>
|
|
||||||
public decimal Amount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 账单数量。
|
|
||||||
/// </summary>
|
|
||||||
public int BillCount { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
namespace TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅概览。
|
|
||||||
/// </summary>
|
|
||||||
public record SubscriptionOverviewDto
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 活跃订阅总数。
|
|
||||||
/// </summary>
|
|
||||||
public int TotalActive { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 7天内到期数量。
|
|
||||||
/// </summary>
|
|
||||||
public int ExpiringIn7Days { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 3天内到期数量。
|
|
||||||
/// </summary>
|
|
||||||
public int ExpiringIn3Days { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 1天内到期数量。
|
|
||||||
/// </summary>
|
|
||||||
public int ExpiringIn1Day { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已过期数量。
|
|
||||||
/// </summary>
|
|
||||||
public int Expired { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 待激活数量。
|
|
||||||
/// </summary>
|
|
||||||
public int Pending { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 已暂停数量。
|
|
||||||
/// </summary>
|
|
||||||
public int Suspended { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取即将到期的订阅列表处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetExpiringSubscriptionsQueryHandler(IStatisticsRepository statisticsRepository)
|
|
||||||
: IRequestHandler<GetExpiringSubscriptionsQuery, IReadOnlyList<ExpiringSubscriptionDto>>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<ExpiringSubscriptionDto>> Handle(GetExpiringSubscriptionsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// 查询即将到期的订阅
|
|
||||||
var items = await statisticsRepository.GetExpiringSubscriptionsAsync(
|
|
||||||
request.DaysAhead,
|
|
||||||
request.OnlyWithoutAutoRenew,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// 映射为 DTO
|
|
||||||
return items.Select(x => new ExpiringSubscriptionDto
|
|
||||||
{
|
|
||||||
Id = x.Subscription.Id,
|
|
||||||
TenantId = x.Subscription.TenantId.ToString(),
|
|
||||||
TenantName = x.TenantName,
|
|
||||||
PackageName = x.PackageName,
|
|
||||||
Status = x.Subscription.Status,
|
|
||||||
EffectiveTo = x.Subscription.EffectiveTo,
|
|
||||||
DaysRemaining = (int)(x.Subscription.EffectiveTo - now).TotalDays,
|
|
||||||
AutoRenew = x.Subscription.AutoRenew
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取配额使用排行处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetQuotaUsageRankingQueryHandler(IStatisticsRepository statisticsRepository)
|
|
||||||
: IRequestHandler<GetQuotaUsageRankingQuery, QuotaUsageRankingDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<QuotaUsageRankingDto> Handle(GetQuotaUsageRankingQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
// 查询指定类型的配额使用排行
|
|
||||||
var items = await statisticsRepository.GetQuotaUsageRankingAsync(
|
|
||||||
request.QuotaType,
|
|
||||||
request.TopN,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// 映射为 DTO
|
|
||||||
var rankings = items.Select(x => new QuotaUsageRankItem
|
|
||||||
{
|
|
||||||
TenantId = x.TenantId.ToString(),
|
|
||||||
TenantName = x.TenantName,
|
|
||||||
UsedValue = x.UsedValue,
|
|
||||||
LimitValue = x.LimitValue,
|
|
||||||
UsagePercentage = x.UsagePercentage
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
return new QuotaUsageRankingDto
|
|
||||||
{
|
|
||||||
QuotaType = request.QuotaType,
|
|
||||||
Rankings = rankings
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取收入统计处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetRevenueStatisticsQueryHandler(IStatisticsRepository statisticsRepository)
|
|
||||||
: IRequestHandler<GetRevenueStatisticsQuery, RevenueStatisticsDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<RevenueStatisticsDto> Handle(GetRevenueStatisticsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var currentMonth = new DateTime(now.Year, now.Month, 1);
|
|
||||||
var currentQuarter = GetQuarterStart(now);
|
|
||||||
var startMonth = currentMonth.AddMonths(-request.MonthsCount + 1);
|
|
||||||
|
|
||||||
// 查询所有已付款的账单
|
|
||||||
var bills = await statisticsRepository.GetPaidBillsAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 总收入
|
|
||||||
var totalRevenue = bills.Sum(b => b.AmountPaid);
|
|
||||||
|
|
||||||
// 本月收入
|
|
||||||
var monthlyRevenue = bills
|
|
||||||
.Where(b => b.PeriodStart >= currentMonth)
|
|
||||||
.Sum(b => b.AmountPaid);
|
|
||||||
|
|
||||||
// 本季度收入
|
|
||||||
var quarterlyRevenue = bills
|
|
||||||
.Where(b => b.PeriodStart >= currentQuarter)
|
|
||||||
.Sum(b => b.AmountPaid);
|
|
||||||
|
|
||||||
// 月度收入明细
|
|
||||||
var monthlyDetails = bills
|
|
||||||
.Where(b => b.PeriodStart >= startMonth)
|
|
||||||
.GroupBy(b => new { b.PeriodStart.Year, b.PeriodStart.Month })
|
|
||||||
.Select(g => new MonthlyRevenueItem
|
|
||||||
{
|
|
||||||
Year = g.Key.Year,
|
|
||||||
Month = g.Key.Month,
|
|
||||||
Amount = g.Sum(b => b.AmountPaid),
|
|
||||||
BillCount = g.Count()
|
|
||||||
})
|
|
||||||
.OrderBy(m => m.Year)
|
|
||||||
.ThenBy(m => m.Month)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new RevenueStatisticsDto
|
|
||||||
{
|
|
||||||
TotalRevenue = totalRevenue,
|
|
||||||
MonthlyRevenue = monthlyRevenue,
|
|
||||||
QuarterlyRevenue = quarterlyRevenue,
|
|
||||||
MonthlyDetails = monthlyDetails
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取季度开始时间。
|
|
||||||
/// </summary>
|
|
||||||
private static DateTime GetQuarterStart(DateTime date)
|
|
||||||
{
|
|
||||||
var quarter = (date.Month - 1) / 3;
|
|
||||||
var quarterStartMonth = quarter * 3 + 1;
|
|
||||||
return new DateTime(date.Year, quarterStartMonth, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Handlers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取订阅概览统计处理器。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GetSubscriptionOverviewQueryHandler(IStatisticsRepository statisticsRepository)
|
|
||||||
: IRequestHandler<GetSubscriptionOverviewQuery, SubscriptionOverviewDto>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<SubscriptionOverviewDto> Handle(GetSubscriptionOverviewQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var in7Days = now.AddDays(7);
|
|
||||||
var in3Days = now.AddDays(3);
|
|
||||||
var in1Day = now.AddDays(1);
|
|
||||||
|
|
||||||
// 查询所有订阅
|
|
||||||
var subscriptions = await statisticsRepository.GetAllSubscriptionsAsync(cancellationToken);
|
|
||||||
|
|
||||||
// 统计各项数据
|
|
||||||
var overview = new SubscriptionOverviewDto
|
|
||||||
{
|
|
||||||
TotalActive = subscriptions.Count(s => s.Status == SubscriptionStatus.Active),
|
|
||||||
ExpiringIn7Days = subscriptions.Count(s =>
|
|
||||||
s.Status == SubscriptionStatus.Active &&
|
|
||||||
s.EffectiveTo >= now &&
|
|
||||||
s.EffectiveTo <= in7Days),
|
|
||||||
ExpiringIn3Days = subscriptions.Count(s =>
|
|
||||||
s.Status == SubscriptionStatus.Active &&
|
|
||||||
s.EffectiveTo >= now &&
|
|
||||||
s.EffectiveTo <= in3Days),
|
|
||||||
ExpiringIn1Day = subscriptions.Count(s =>
|
|
||||||
s.Status == SubscriptionStatus.Active &&
|
|
||||||
s.EffectiveTo >= now &&
|
|
||||||
s.EffectiveTo <= in1Day),
|
|
||||||
Expired = subscriptions.Count(s => s.Status == SubscriptionStatus.GracePeriod),
|
|
||||||
Pending = subscriptions.Count(s => s.Status == SubscriptionStatus.Pending),
|
|
||||||
Suspended = subscriptions.Count(s => s.Status == SubscriptionStatus.Suspended)
|
|
||||||
};
|
|
||||||
|
|
||||||
return overview;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取即将到期的订阅列表。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetExpiringSubscriptionsQuery : IRequest<IReadOnlyList<ExpiringSubscriptionDto>>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 筛选天数,默认7天内到期。
|
|
||||||
/// </summary>
|
|
||||||
public int DaysAhead { get; init; } = 7;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否只返回未开启自动续费的订阅。
|
|
||||||
/// </summary>
|
|
||||||
public bool OnlyWithoutAutoRenew { get; init; } = false;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取配额使用排行。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetQuotaUsageRankingQuery : IRequest<QuotaUsageRankingDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 配额类型。
|
|
||||||
/// </summary>
|
|
||||||
public TenantQuotaType QuotaType { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 返回前N条记录,默认前10。
|
|
||||||
/// </summary>
|
|
||||||
public int TopN { get; init; } = 10;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取收入统计。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetRevenueStatisticsQuery : IRequest<RevenueStatisticsDto>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 统计月份数量,默认12个月。
|
|
||||||
/// </summary>
|
|
||||||
public int MonthsCount { get; init; } = 12;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using TakeoutSaaS.Application.App.Statistics.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Statistics.Queries;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取订阅概览统计。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GetSubscriptionOverviewQuery : IRequest<SubscriptionOverviewDto>
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量延期订阅命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BatchExtendSubscriptionsCommand : IRequest<BatchExtendResult>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅ID列表。
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
|
|
||||||
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 延期时长(天)。
|
|
||||||
/// </summary>
|
|
||||||
[Range(1, 3650, ErrorMessage = "延期天数必须在1-3650天之间")]
|
|
||||||
public int? DurationDays { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 延期时长(月)。
|
|
||||||
/// </summary>
|
|
||||||
[Range(1, 120, ErrorMessage = "延期月数必须在1-120月之间")]
|
|
||||||
public int? DurationMonths { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
[MaxLength(500)]
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量延期结果。
|
|
||||||
/// </summary>
|
|
||||||
public record BatchExtendResult
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 成功数量。
|
|
||||||
/// </summary>
|
|
||||||
public int SuccessCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 失败数量。
|
|
||||||
/// </summary>
|
|
||||||
public int FailureCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 失败详情列表。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量操作失败项。
|
|
||||||
/// </summary>
|
|
||||||
public record BatchFailureItem
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅ID。
|
|
||||||
/// </summary>
|
|
||||||
public long SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 失败原因。
|
|
||||||
/// </summary>
|
|
||||||
public string Reason { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量发送续费提醒命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record BatchSendReminderCommand : IRequest<BatchSendReminderResult>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅ID列表。
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[MinLength(1, ErrorMessage = "至少需要选择一个订阅")]
|
|
||||||
public IReadOnlyList<long> SubscriptionIds { get; init; } = Array.Empty<long>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提醒内容。
|
|
||||||
/// </summary>
|
|
||||||
[Required(ErrorMessage = "提醒内容不能为空")]
|
|
||||||
[MaxLength(1000, ErrorMessage = "提醒内容不能超过1000字符")]
|
|
||||||
public string ReminderContent { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 批量发送提醒结果。
|
|
||||||
/// </summary>
|
|
||||||
public record BatchSendReminderResult
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 成功发送数量。
|
|
||||||
/// </summary>
|
|
||||||
public int SuccessCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 发送失败数量。
|
|
||||||
/// </summary>
|
|
||||||
public int FailureCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 失败详情列表。
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<BatchFailureItem> Failures { get; init; } = Array.Empty<BatchFailureItem>();
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Subscriptions.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 变更套餐命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ChangeSubscriptionPlanCommand : IRequest<SubscriptionDetailDto?>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅 ID(从路由参数绑定)。
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public long SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 目标套餐 ID。
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public long TargetPackageId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否立即生效,否则在下周期生效。
|
|
||||||
/// </summary>
|
|
||||||
public bool Immediate { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
[MaxLength(500)]
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using TakeoutSaaS.Application.App.Subscriptions.Dto;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.App.Subscriptions.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 延期订阅命令。
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ExtendSubscriptionCommand : IRequest<SubscriptionDetailDto?>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 订阅 ID(从路由参数绑定)。
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public long SubscriptionId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 延期时长(月)。
|
|
||||||
/// </summary>
|
|
||||||
[Range(1, 120)]
|
|
||||||
public int DurationMonths { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 备注信息。
|
|
||||||
/// </summary>
|
|
||||||
[MaxLength(500)]
|
|
||||||
public string? Notes { get; init; }
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user