feat:商户管理

This commit is contained in:
2025-12-29 16:40:27 +08:00
parent 57f4c2d394
commit dd91c1010a
62 changed files with 10536 additions and 165 deletions

View File

@@ -1,6 +1,7 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
@@ -41,36 +42,32 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
/// <summary>
/// 查询商户列表。
/// </summary>
/// <param name="status">状态筛选。</param>
/// <param name="page">页码。</param>
/// <param name="pageSize">每页大小。</param>
/// <param name="sortBy">排序字段。</param>
/// <param name="sortDesc">是否倒序。</param>
/// <param name="query">查询参数。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>商户分页结果。</returns>
[HttpGet]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<MerchantDto>>> List(
[FromQuery] MerchantStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? sortBy = null,
[FromQuery] bool sortDesc = true,
[ProducesResponseType(typeof(ApiResponse<PagedResult<MerchantListItemDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<MerchantListItemDto>>> List(
[FromQuery] GetMerchantListQuery query,
CancellationToken cancellationToken = default)
{
// 1. 组装查询参数并执行查询
var result = await mediator.Send(new SearchMerchantsQuery
{
Status = status,
Page = page,
PageSize = pageSize,
SortBy = sortBy,
SortDescending = sortDesc
}, cancellationToken);
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<MerchantListItemDto>>.Ok(result);
}
// 2. 返回分页结果
return ApiResponse<PagedResult<MerchantDto>>.Ok(result);
/// <summary>
/// 待审核商户列表。
/// </summary>
[HttpGet("pending-review")]
[PermissionAuthorize("merchant:review")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<MerchantReviewListItemDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<MerchantReviewListItemDto>>> PendingReviewList(
[FromQuery] GetPendingReviewListQuery query,
CancellationToken cancellationToken)
{
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<MerchantReviewListItemDto>>.Ok(result);
}
/// <summary>
@@ -82,23 +79,36 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
/// <returns>更新后的商户或未找到。</returns>
[HttpPut("{merchantId:long}")]
[PermissionAuthorize("merchant:update")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<UpdateMerchantResultDto>), StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<MerchantDto>> Update(long merchantId, [FromBody] UpdateMerchantCommand command, CancellationToken cancellationToken)
public async Task<ApiResponse<UpdateMerchantResultDto>> Update(long merchantId, [FromBody] UpdateMerchantCommand command, CancellationToken cancellationToken)
{
// 1. 绑定商户标识
if (command.MerchantId == 0)
if (command.MerchantId != 0 && command.MerchantId != merchantId)
{
command = command with { MerchantId = merchantId };
return ApiResponse<UpdateMerchantResultDto>.Error(StatusCodes.Status400BadRequest, "路由 merchantId 与请求体 merchantId 不一致");
}
command = command with { MerchantId = merchantId };
// 2. 执行更新
var result = await mediator.Send(command, cancellationToken);
// 3. 返回更新结果或 404
return result == null
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
: ApiResponse<MerchantDto>.Ok(result);
if (result == null)
{
return ApiResponse<UpdateMerchantResultDto>.Error(ErrorCodes.NotFound, "商户不存在");
}
if (result.RequiresReview)
{
return ApiResponse<UpdateMerchantResultDto>.Error(
ErrorCodes.ValidationFailed,
"关键信息修改,商户已进入待审核状态,业务已冻结")
with { Data = result };
}
return ApiResponse<UpdateMerchantResultDto>.Ok(result);
}
/// <summary>
@@ -130,17 +140,51 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
/// <returns>商户概览或未找到。</returns>
[HttpGet("{merchantId:long}")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<MerchantDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<MerchantDto>> Detail(long merchantId, CancellationToken cancellationToken)
public async Task<ApiResponse<MerchantDetailDto>> Detail(long merchantId, CancellationToken cancellationToken)
{
// 1. 查询商户概览
var result = await mediator.Send(new GetMerchantByIdQuery { MerchantId = merchantId }, cancellationToken);
var result = await mediator.Send(new GetMerchantDetailQuery(merchantId), cancellationToken);
// 2. 返回结果或 404
return result == null
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
: ApiResponse<MerchantDto>.Ok(result);
// 2. 返回结果
return ApiResponse<MerchantDetailDto>.Ok(result);
}
/// <summary>
/// 获取审核领取信息。
/// </summary>
[HttpGet("{merchantId:long}/review/claim")]
[PermissionAuthorize("merchant:review")]
[ProducesResponseType(typeof(ApiResponse<ClaimInfoDto?>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ClaimInfoDto?>> GetReviewClaim(long merchantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantReviewClaimQuery(merchantId), cancellationToken);
return ApiResponse<ClaimInfoDto?>.Ok(result);
}
/// <summary>
/// 领取审核。
/// </summary>
[HttpPost("{merchantId:long}/review/claim")]
[PermissionAuthorize("merchant:review")]
[ProducesResponseType(typeof(ApiResponse<ClaimInfoDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ClaimInfoDto>> ClaimReview(long merchantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new ClaimMerchantReviewCommand { MerchantId = merchantId }, cancellationToken);
return ApiResponse<ClaimInfoDto>.Ok(result);
}
/// <summary>
/// 释放审核领取。
/// </summary>
[HttpDelete("{merchantId:long}/review/claim")]
[PermissionAuthorize("merchant:review")]
[ProducesResponseType(typeof(ApiResponse<ClaimInfoDto?>), StatusCodes.Status200OK)]
public async Task<ApiResponse<ClaimInfoDto?>> ReleaseReviewClaim(long merchantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new ReleaseClaimCommand { MerchantId = merchantId }, cancellationToken);
return ApiResponse<ClaimInfoDto?>.Ok(result);
}
/// <summary>
@@ -290,6 +334,60 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
return ApiResponse<MerchantDto>.Ok(result);
}
/// <summary>
/// 撤销审核。
/// </summary>
[HttpPost("{merchantId:long}/review/revoke")]
[PermissionAuthorize("merchant:review:revoke")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> RevokeReview(
long merchantId,
[FromBody] RevokeMerchantReviewCommand body,
CancellationToken cancellationToken)
{
if (body.MerchantId != 0 && body.MerchantId != merchantId)
{
return ApiResponse<object>.Error(StatusCodes.Status400BadRequest, "路由 merchantId 与请求体 merchantId 不一致");
}
var command = new RevokeMerchantReviewCommand
{
MerchantId = merchantId,
Reason = body.Reason
};
await mediator.Send(command, cancellationToken);
return ApiResponse<object>.Ok(null);
}
/// <summary>
/// 审核历史。
/// </summary>
[HttpGet("{merchantId:long}/audit-history")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantAuditLogDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MerchantAuditLogDto>>> AuditHistory(
long merchantId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantAuditHistoryQuery(merchantId), cancellationToken);
return ApiResponse<IReadOnlyList<MerchantAuditLogDto>>.Ok(result);
}
/// <summary>
/// 变更历史。
/// </summary>
[HttpGet("{merchantId:long}/change-history")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantChangeLogDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MerchantChangeLogDto>>> ChangeHistory(
long merchantId,
[FromQuery] string? fieldName,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantChangeHistoryQuery(merchantId, fieldName), cancellationToken);
return ApiResponse<IReadOnlyList<MerchantChangeLogDto>>.Ok(result);
}
/// <summary>
/// 审核日志。
/// </summary>
@@ -310,6 +408,27 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
return ApiResponse<PagedResult<MerchantAuditLogDto>>.Ok(result);
}
/// <summary>
/// 导出商户 PDF。
/// </summary>
[HttpGet("{merchantId:long}/export-pdf")]
[PermissionAuthorize("merchant:export")]
[Produces("application/pdf")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ExportPdf(long merchantId, CancellationToken cancellationToken)
{
var bytes = await mediator.Send(new ExportMerchantPdfQuery(merchantId), cancellationToken);
var fileName = $"merchant_{merchantId}_{DateTime.UtcNow:yyyyMMdd_HHmmss}.pdf";
Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment")
{
FileName = fileName,
FileNameStar = fileName
}.ToString();
return File(bytes, "application/pdf");
}
/// <summary>
/// 可选商户类目列表。
/// </summary>

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 领取商户审核命令。
/// </summary>
public sealed class ClaimMerchantReviewCommand : IRequest<ClaimInfoDto>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 释放商户审核领取命令。
/// </summary>
public sealed class ReleaseClaimCommand : IRequest<ClaimInfoDto?>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 撤销商户审核命令。
/// </summary>
public sealed class RevokeMerchantReviewCommand : IRequest
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
/// <summary>
/// 撤销原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@@ -1,13 +1,12 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Enums;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 更新商户命令。
/// </summary>
public sealed record UpdateMerchantCommand : IRequest<MerchantDto?>
public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
{
/// <summary>
/// 商户 ID。
@@ -15,29 +14,29 @@ public sealed record UpdateMerchantCommand : IRequest<MerchantDto?>
public long MerchantId { get; init; }
/// <summary>
/// 品牌名称。
/// 商户名称。
/// </summary>
public string BrandName { get; init; } = string.Empty;
public string? Name { get; init; }
/// <summary>
/// 品牌简称
/// 营业执照号
/// </summary>
public string? BrandAlias { get; init; }
public string? LicenseNumber { get; init; }
/// <summary>
/// Logo 地址
/// 法人或负责人
/// </summary>
public string? LogoUrl { get; init; }
public string? LegalRepresentative { get; init; }
/// <summary>
/// 品类
/// 注册地址
/// </summary>
public string? Category { get; init; }
public string? RegisteredAddress { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string ContactPhone { get; init; } = string.Empty;
public string? ContactPhone { get; init; }
/// <summary>
/// 联系邮箱。
@@ -45,7 +44,7 @@ public sealed record UpdateMerchantCommand : IRequest<MerchantDto?>
public string? ContactEmail { get; init; }
/// <summary>
/// 入驻状态
/// 并发控制版本
/// </summary>
public MerchantStatus Status { get; init; }
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
}

View File

@@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 审核领取信息 DTO。
/// </summary>
public sealed class ClaimInfoDto
{
/// <summary>
/// 商户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long MerchantId { get; init; }
/// <summary>
/// 领取人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? ClaimedBy { get; init; }
/// <summary>
/// 领取人名称。
/// </summary>
public string? ClaimedByName { get; init; }
/// <summary>
/// 领取时间。
/// </summary>
public DateTime? ClaimedAt { get; init; }
/// <summary>
/// 领取过期时间。
/// </summary>
public DateTime? ClaimExpiresAt { get; init; }
}

View File

@@ -26,6 +26,12 @@ public sealed class MerchantAuditLogDto
/// </summary>
public MerchantAuditAction Action { get; init; }
/// <summary>
/// 操作人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? OperatorId { get; init; }
/// <summary>
/// 标题。
/// </summary>
@@ -41,6 +47,11 @@ public sealed class MerchantAuditLogDto
/// </summary>
public string? OperatorName { get; init; }
/// <summary>
/// 操作 IP。
/// </summary>
public string? IpAddress { get; init; }
/// <summary>
/// 创建时间。
/// </summary>

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户变更日志 DTO。
/// </summary>
public sealed class MerchantChangeLogDto
{
/// <summary>
/// 日志 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 变更字段。
/// </summary>
public string FieldName { get; init; } = string.Empty;
/// <summary>
/// 变更前值。
/// </summary>
public string? OldValue { get; init; }
/// <summary>
/// 变更后值。
/// </summary>
public string? NewValue { get; init; }
/// <summary>
/// 变更人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? ChangedBy { get; init; }
/// <summary>
/// 变更人名称。
/// </summary>
public string? ChangedByName { get; init; }
/// <summary>
/// 变更时间。
/// </summary>
public DateTime ChangedAt { get; init; }
/// <summary>
/// 变更原因。
/// </summary>
public string? ChangeReason { get; init; }
}

View File

@@ -1,3 +1,8 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
@@ -6,17 +11,117 @@ namespace TakeoutSaaS.Application.App.Merchants.Dto;
public sealed class MerchantDetailDto
{
/// <summary>
/// 基础信息
/// 商户 ID
/// </summary>
public MerchantDto Merchant { get; init; } = new();
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 证照列表
/// 租户 ID
/// </summary>
public IReadOnlyList<MerchantDocumentDto> Documents { get; init; } = [];
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 合同列表
/// 租户名称
/// </summary>
public IReadOnlyList<MerchantContractDto> Contracts { get; init; } = [];
public string? TenantName { get; init; }
/// <summary>
/// 商户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 经营模式。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? LicenseNumber { get; init; }
/// <summary>
/// 法人或负责人。
/// </summary>
public string? LegalRepresentative { get; init; }
/// <summary>
/// 注册地址。
/// </summary>
public string? RegisteredAddress { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 联系邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public MerchantStatus Status { get; init; }
/// <summary>
/// 业务冻结标记。
/// </summary>
public bool IsFrozen { get; init; }
/// <summary>
/// 冻结原因。
/// </summary>
public string? FrozenReason { get; init; }
/// <summary>
/// 冻结时间。
/// </summary>
public DateTime? FrozenAt { get; init; }
/// <summary>
/// 审核通过人。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? ApprovedBy { get; init; }
/// <summary>
/// 审核通过时间。
/// </summary>
public DateTime? ApprovedAt { get; init; }
/// <summary>
/// 门店列表。
/// </summary>
public IReadOnlyList<StoreDto> Stores { get; init; } = [];
/// <summary>
/// 并发控制版本。
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 创建人。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? CreatedBy { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
/// <summary>
/// 更新人。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? UpdatedBy { get; init; }
}

View File

@@ -0,0 +1,69 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户列表项 DTO。
/// </summary>
public sealed class MerchantListItemDto
{
/// <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 Name { get; init; } = string.Empty;
/// <summary>
/// 经营模式。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? LicenseNumber { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public MerchantStatus Status { get; init; }
/// <summary>
/// 是否冻结业务。
/// </summary>
public bool IsFrozen { get; init; }
/// <summary>
/// 门店数量。
/// </summary>
public int StoreCount { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 更新时间。
/// </summary>
public DateTime? UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,69 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 待审核商户列表项 DTO。
/// </summary>
public sealed class MerchantReviewListItemDto
{
/// <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 Name { get; init; } = string.Empty;
/// <summary>
/// 经营模式。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? LicenseNumber { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public MerchantStatus Status { get; init; }
/// <summary>
/// 领取人名称。
/// </summary>
public string? ClaimedByName { get; init; }
/// <summary>
/// 领取时间。
/// </summary>
public DateTime? ClaimedAt { get; init; }
/// <summary>
/// 领取过期时间。
/// </summary>
public DateTime? ClaimExpiresAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户详情门店 DTO。
/// </summary>
public sealed class StoreDto
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 门店名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 营业执照号(主体不一致模式使用)。
/// </summary>
public string? LicenseNumber { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 门店地址。
/// </summary>
public string? Address { get; init; }
/// <summary>
/// 门店状态。
/// </summary>
public StoreStatus Status { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户更新结果 DTO。
/// </summary>
public sealed class UpdateMerchantResultDto
{
/// <summary>
/// 更新后的商户详情。
/// </summary>
public MerchantDetailDto Merchant { get; init; } = new();
/// <summary>
/// 是否触发重新审核。
/// </summary>
public bool RequiresReview { get; init; }
}

View File

@@ -0,0 +1,77 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 领取商户审核处理器。
/// </summary>
public sealed class ClaimMerchantReviewHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ClaimMerchantReviewCommand, ClaimInfoDto>
{
/// <inheritdoc />
public async Task<ClaimInfoDto> Handle(ClaimMerchantReviewCommand request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (merchant.Status != MerchantStatus.Pending)
{
throw new BusinessException(ErrorCodes.Conflict, "商户不在待审核状态");
}
var now = DateTime.UtcNow;
if (merchant.ClaimedBy.HasValue && merchant.ClaimExpiresAt.HasValue && merchant.ClaimExpiresAt > now)
{
if (merchant.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {merchant.ClaimedByName} 领取");
}
return ToDto(merchant);
}
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
merchant.ClaimedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
merchant.ClaimedByName = actorName;
merchant.ClaimedAt = now;
merchant.ClaimExpiresAt = now.AddMinutes(30);
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewClaimed,
Title = "领取审核",
Description = $"领取人:{actorName}",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
return ToDto(merchant);
}
private static ClaimInfoDto ToDto(Domain.Merchants.Entities.Merchant merchant)
=> new()
{
MerchantId = merchant.Id,
ClaimedBy = merchant.ClaimedBy,
ClaimedByName = merchant.ClaimedByName,
ClaimedAt = merchant.ClaimedAt,
ClaimExpiresAt = merchant.ClaimExpiresAt
};
}

View File

@@ -0,0 +1,55 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Application.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Merchants.Services;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 导出商户 PDF 处理器。
/// </summary>
public sealed class ExportMerchantPdfQueryHandler(
IMerchantRepository merchantRepository,
IStoreRepository storeRepository,
ITenantRepository tenantRepository,
IMerchantExportService exportService,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<ExportMerchantPdfQuery, byte[]>
{
public async Task<byte[]> Handle(ExportMerchantPdfQuery request, CancellationToken cancellationToken)
{
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
var merchant = isSuperAdmin
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
if (merchant == null)
{
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
}
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止导出其他租户商户");
}
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
var auditLogs = await merchantRepository.GetAuditLogsAsync(merchant.Id, merchant.TenantId, cancellationToken);
var tenant = await tenantRepository.FindByIdAsync(merchant.TenantId, cancellationToken);
return await exportService.ExportToPdfAsync(merchant, tenant?.Name, stores, auditLogs, cancellationToken);
}
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Application.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户审核历史处理器。
/// </summary>
public sealed class GetMerchantAuditHistoryQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<GetMerchantAuditHistoryQuery, IReadOnlyList<MerchantAuditLogDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantAuditLogDto>> Handle(
GetMerchantAuditHistoryQuery request,
CancellationToken cancellationToken)
{
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
var merchant = isSuperAdmin
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
if (merchant == null)
{
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
}
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户审核历史");
}
var logs = await merchantRepository.GetAuditLogsAsync(merchant.Id, merchant.TenantId, cancellationToken);
return logs.Select(MerchantMapping.ToDto).ToList();
}
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Application.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户变更历史处理器。
/// </summary>
public sealed class GetMerchantChangeHistoryQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<GetMerchantChangeHistoryQuery, IReadOnlyList<MerchantChangeLogDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantChangeLogDto>> Handle(
GetMerchantChangeHistoryQuery request,
CancellationToken cancellationToken)
{
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
var merchant = isSuperAdmin
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
if (merchant == null)
{
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
}
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户变更历史");
}
var logs = await merchantRepository.GetChangeLogsAsync(merchant.Id, merchant.TenantId, request.FieldName, cancellationToken);
return logs.Select(MerchantMapping.ToDto).ToList();
}
}

View File

@@ -1,9 +1,14 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Application.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
@@ -13,7 +18,11 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// </summary>
public sealed class GetMerchantDetailQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider)
IStoreRepository storeRepository,
ITenantRepository tenantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<GetMerchantDetailQuery, MerchantDetailDto>
{
/// <summary>
@@ -24,21 +33,31 @@ public sealed class GetMerchantDetailQueryHandler(
/// <returns>商户详情 DTO。</returns>
public async Task<MerchantDetailDto> Handle(GetMerchantDetailQuery request, CancellationToken cancellationToken)
{
// 1. 获取租户上下文并查询商户
var tenantId = tenantProvider.GetCurrentTenantId();
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
// 1. 获取权限与商户
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 查询证照与合同
var documents = await merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken);
var contracts = await merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken);
var merchant = isSuperAdmin
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
if (merchant == null)
{
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
}
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户");
}
// 2. 查询门店与租户信息
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
var storeDtos = MerchantMapping.ToStoreDtos(stores);
var tenant = await tenantRepository.FindByIdAsync(merchant.TenantId, cancellationToken);
// 3. 返回明细 DTO
return new MerchantDetailDto
{
Merchant = MerchantMapping.ToDto(merchant),
Documents = MerchantMapping.ToDocumentDtos(documents),
Contracts = MerchantMapping.ToContractDtos(contracts)
};
return MerchantMapping.ToDetailDto(merchant, tenant?.Name, storeDtos);
}
}

View File

@@ -0,0 +1,106 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Application.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户列表查询处理器。
/// </summary>
public sealed class GetMerchantListQueryHandler(
IMerchantRepository merchantRepository,
IStoreRepository storeRepository,
ITenantRepository tenantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<GetMerchantListQuery, PagedResult<MerchantListItemDto>>
{
/// <inheritdoc />
public async Task<PagedResult<MerchantListItemDto>> Handle(
GetMerchantListQuery request,
CancellationToken cancellationToken)
{
// 1. 校验跨租户访问权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询商户");
}
var effectiveTenantId = isSuperAdmin ? request.TenantId : currentTenantId;
// 2. 查询商户列表
var merchants = await merchantRepository.SearchAsync(
effectiveTenantId,
request.Status,
request.OperatingMode,
request.Keyword,
cancellationToken);
if (merchants.Count == 0)
{
return new PagedResult<MerchantListItemDto>(Array.Empty<MerchantListItemDto>(), request.Page, request.PageSize, 0);
}
// 3. 排序 & 分页
var sorted = ApplySorting(merchants, request.SortBy, request.SortOrder);
var total = sorted.Count;
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
if (paged.Count == 0)
{
return new PagedResult<MerchantListItemDto>(Array.Empty<MerchantListItemDto>(), request.Page, request.PageSize, total);
}
// 4. 批量查询租户名称
var tenantIds = paged.Select(x => x.TenantId).Distinct().ToArray();
var tenants = await tenantRepository.FindByIdsAsync(tenantIds, cancellationToken);
var tenantLookup = tenants.ToDictionary(x => x.Id, x => x.Name);
// 5. 批量查询门店数量
var merchantIds = paged.Select(x => x.Id).ToArray();
var storeCounts = await storeRepository.GetStoreCountsAsync(effectiveTenantId, merchantIds, cancellationToken);
// 6. 组装 DTO
var items = paged.Select(merchant =>
{
var tenantName = tenantLookup.TryGetValue(merchant.TenantId, out var name) ? name : null;
var count = storeCounts.TryGetValue(merchant.Id, out var value) ? value : 0;
return MerchantMapping.ToListItemDto(merchant, tenantName, count);
}).ToList();
return new PagedResult<MerchantListItemDto>(items, request.Page, request.PageSize, total);
}
private static List<Domain.Merchants.Entities.Merchant> ApplySorting(
IReadOnlyList<Domain.Merchants.Entities.Merchant> merchants,
string? sortBy,
string? sortOrder)
{
var descending = !string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase);
return (sortBy ?? string.Empty).Trim().ToLowerInvariant() switch
{
"name" => descending ? merchants.OrderByDescending(x => x.BrandName).ToList() : merchants.OrderBy(x => x.BrandName).ToList(),
"status" => descending ? merchants.OrderByDescending(x => x.Status).ToList() : merchants.OrderBy(x => x.Status).ToList(),
"updatedat" => descending ? merchants.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt).ToList() : merchants.OrderBy(x => x.UpdatedAt ?? x.CreatedAt).ToList(),
_ => descending ? merchants.OrderByDescending(x => x.CreatedAt).ToList() : merchants.OrderBy(x => x.CreatedAt).ToList()
};
}
}

View File

@@ -0,0 +1,41 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户审核领取信息查询处理器。
/// </summary>
public sealed class GetMerchantReviewClaimQueryHandler(IMerchantRepository merchantRepository)
: IRequestHandler<GetMerchantReviewClaimQuery, ClaimInfoDto?>
{
/// <inheritdoc />
public async Task<ClaimInfoDto?> Handle(GetMerchantReviewClaimQuery request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (!merchant.ClaimedBy.HasValue)
{
return null;
}
if (merchant.ClaimExpiresAt.HasValue && merchant.ClaimExpiresAt <= DateTime.UtcNow)
{
return null;
}
return new ClaimInfoDto
{
MerchantId = merchant.Id,
ClaimedBy = merchant.ClaimedBy,
ClaimedByName = merchant.ClaimedByName,
ClaimedAt = merchant.ClaimedAt,
ClaimExpiresAt = merchant.ClaimExpiresAt
};
}
}

View File

@@ -0,0 +1,59 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 待审核商户列表处理器。
/// </summary>
public sealed class GetPendingReviewListQueryHandler(
IMerchantRepository merchantRepository,
ITenantRepository tenantRepository)
: IRequestHandler<GetPendingReviewListQuery, PagedResult<MerchantReviewListItemDto>>
{
/// <inheritdoc />
public async Task<PagedResult<MerchantReviewListItemDto>> Handle(
GetPendingReviewListQuery request,
CancellationToken cancellationToken)
{
var merchants = await merchantRepository.SearchAsync(
request.TenantId,
MerchantStatus.Pending,
request.OperatingMode,
request.Keyword,
cancellationToken);
var total = merchants.Count;
var paged = merchants
.OrderByDescending(x => x.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var tenantIds = paged.Select(x => x.TenantId).Distinct().ToArray();
var tenants = await tenantRepository.FindByIdsAsync(tenantIds, cancellationToken);
var tenantLookup = tenants.ToDictionary(x => x.Id, x => x.Name);
var items = paged.Select(merchant => new MerchantReviewListItemDto
{
Id = merchant.Id,
TenantId = merchant.TenantId,
TenantName = tenantLookup.TryGetValue(merchant.TenantId, out var name) ? name : null,
Name = merchant.BrandName,
OperatingMode = merchant.OperatingMode,
LicenseNumber = merchant.BusinessLicenseNumber,
Status = merchant.Status,
ClaimedByName = merchant.ClaimedByName,
ClaimedAt = merchant.ClaimedAt,
ClaimExpiresAt = merchant.ClaimExpiresAt,
CreatedAt = merchant.CreatedAt
}).ToList();
return new PagedResult<MerchantReviewListItemDto>(items, request.Page, request.PageSize, total);
}
}

View File

@@ -0,0 +1,64 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 释放审核领取处理器。
/// </summary>
public sealed class ReleaseClaimHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReleaseClaimCommand, ClaimInfoDto?>
{
/// <inheritdoc />
public async Task<ClaimInfoDto?> Handle(ReleaseClaimCommand request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (!merchant.ClaimedBy.HasValue)
{
return null;
}
var now = DateTime.UtcNow;
var claimExpired = merchant.ClaimExpiresAt.HasValue && merchant.ClaimExpiresAt <= now;
if (!claimExpired && merchant.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {merchant.ClaimedByName} 领取");
}
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
merchant.ClaimedBy = null;
merchant.ClaimedByName = null;
merchant.ClaimedAt = null;
merchant.ClaimExpiresAt = null;
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewReleased,
Title = "释放审核",
Description = $"释放人:{actorName}",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
return null;
}
}

View File

@@ -7,7 +7,6 @@ using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// </summary>
public sealed class ReviewMerchantCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewMerchantCommand, MerchantDto>
{
@@ -29,33 +27,62 @@ public sealed class ReviewMerchantCommandHandler(
public async Task<MerchantDto> Handle(ReviewMerchantCommand request, CancellationToken cancellationToken)
{
// 1. 读取商户
var tenantId = tenantProvider.GetCurrentTenantId();
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
// 2. 已审核通过则直接返回
if (request.Approve && merchant.Status == MerchantStatus.Approved)
var now = DateTime.UtcNow;
if (!merchant.ClaimedBy.HasValue || !merchant.ClaimExpiresAt.HasValue || merchant.ClaimExpiresAt <= now)
{
return MerchantMapping.ToDto(merchant);
throw new BusinessException(ErrorCodes.Conflict, "请先领取审核");
}
// 3. 更新审核状态
var previousStatus = merchant.Status;
if (merchant.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {merchant.ClaimedByName} 领取");
}
if (merchant.Status != MerchantStatus.Pending)
{
throw new BusinessException(ErrorCodes.Conflict, "商户不在待审核状态");
}
// 2. 更新审核状态
merchant.Status = request.Approve ? MerchantStatus.Approved : MerchantStatus.Rejected;
merchant.ReviewRemarks = request.Remarks;
merchant.LastReviewedAt = DateTime.UtcNow;
merchant.LastReviewedAt = now;
merchant.LastReviewedBy = ResolveOperatorId();
if (request.Approve && merchant.JoinedAt == null)
{
merchant.JoinedAt = DateTime.UtcNow;
merchant.JoinedAt = now;
}
// 4. 持久化与审计
if (request.Approve)
{
merchant.IsFrozen = false;
merchant.FrozenReason = null;
merchant.FrozenAt = null;
merchant.ApprovedAt = now;
merchant.ApprovedBy = ResolveOperatorId();
}
else
{
merchant.IsFrozen = false;
merchant.FrozenReason = null;
merchant.FrozenAt = null;
}
merchant.ClaimedBy = null;
merchant.ClaimedByName = null;
merchant.ClaimedAt = null;
merchant.ClaimExpiresAt = null;
// 3. 持久化与审计
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenantId,
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.MerchantReviewed,
Action = request.Approve ? MerchantAuditAction.ReviewApproved : MerchantAuditAction.ReviewRejected,
Title = request.Approve ? "商户审核通过" : "商户审核驳回",
Description = request.Remarks,
OperatorId = ResolveOperatorId(),
@@ -63,7 +90,7 @@ public sealed class ReviewMerchantCommandHandler(
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
// 4. 返回 DTO
return MerchantMapping.ToDto(merchant);
}

View File

@@ -0,0 +1,61 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 撤销商户审核处理器。
/// </summary>
public sealed class RevokeMerchantReviewHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<RevokeMerchantReviewCommand>
{
/// <inheritdoc />
public async Task Handle(RevokeMerchantReviewCommand request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (merchant.Status != MerchantStatus.Approved)
{
throw new BusinessException(ErrorCodes.Conflict, "商户不在已审核状态");
}
var now = DateTime.UtcNow;
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
merchant.Status = MerchantStatus.Pending;
merchant.IsFrozen = true;
merchant.FrozenReason = request.Reason;
merchant.FrozenAt = now;
merchant.ReviewRemarks = request.Reason;
merchant.LastReviewedAt = now;
merchant.LastReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
merchant.ClaimedBy = null;
merchant.ClaimedByName = null;
merchant.ClaimedAt = null;
merchant.ClaimExpiresAt = null;
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewRevoked,
Title = "撤销审核",
Description = request.Reason,
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -2,7 +2,16 @@ using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
@@ -12,51 +21,175 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// </summary>
public sealed class UpdateMerchantCommandHandler(
IMerchantRepository merchantRepository,
IStoreRepository storeRepository,
ITenantRepository tenantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
ILogger<UpdateMerchantCommandHandler> logger)
: IRequestHandler<UpdateMerchantCommand, MerchantDto?>
: IRequestHandler<UpdateMerchantCommand, UpdateMerchantResultDto?>
{
/// <inheritdoc />
public async Task<MerchantDto?> Handle(UpdateMerchantCommand request, CancellationToken cancellationToken)
public async Task<UpdateMerchantResultDto?> Handle(UpdateMerchantCommand request, CancellationToken cancellationToken)
{
// 1. 读取现有商户
var tenantId = tenantProvider.GetCurrentTenantId();
var existing = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken);
if (existing == null)
if (request.RowVersion == null || request.RowVersion.Length == 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
}
// 1. 获取操作者权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 读取商户信息
var merchant = isSuperAdmin
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
if (merchant == null)
{
return null;
}
// 2. 更新字段
existing.BrandName = request.BrandName.Trim();
existing.BrandAlias = request.BrandAlias?.Trim();
existing.LogoUrl = request.LogoUrl?.Trim();
existing.Category = request.Category?.Trim();
existing.ContactPhone = request.ContactPhone.Trim();
existing.ContactEmail = request.ContactEmail?.Trim();
existing.Status = request.Status;
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
{
return null;
}
// 3. 持久化
await merchantRepository.UpdateMerchantAsync(existing, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("更新商户 {MerchantId} - {BrandName}", existing.Id, existing.BrandName);
// 3. 规范化输入
var name = NormalizeRequired(request.Name, "商户名称");
var contactPhone = NormalizeRequired(request.ContactPhone, "联系电话");
var licenseNumber = NormalizeOptional(request.LicenseNumber);
var legalRepresentative = NormalizeOptional(request.LegalRepresentative);
var registeredAddress = NormalizeOptional(request.RegisteredAddress);
var contactEmail = NormalizeOptional(request.ContactEmail);
// 4. 返回 DTO
return MapToDto(existing);
var now = DateTime.UtcNow;
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
var actorName = ResolveActorName();
var changes = new List<MerchantChangeLog>();
var criticalChanged = false;
TrackChange("name", merchant.BrandName, name, isCritical: true);
TrackChange("licenseNumber", merchant.BusinessLicenseNumber, licenseNumber, isCritical: true);
TrackChange("legalRepresentative", merchant.LegalPerson, legalRepresentative, isCritical: true);
TrackChange("registeredAddress", merchant.Address, registeredAddress, isCritical: true);
TrackChange("contactPhone", merchant.ContactPhone, contactPhone, isCritical: false);
TrackChange("contactEmail", merchant.ContactEmail, contactEmail, isCritical: false);
// 4. 写入字段
merchant.BrandName = name;
merchant.BusinessLicenseNumber = licenseNumber;
merchant.LegalPerson = legalRepresentative;
merchant.Address = registeredAddress;
merchant.ContactPhone = contactPhone;
merchant.ContactEmail = contactEmail;
merchant.RowVersion = request.RowVersion;
var requiresReview = merchant.Status == MerchantStatus.Approved && criticalChanged;
if (requiresReview)
{
merchant.Status = MerchantStatus.Pending;
merchant.IsFrozen = true;
merchant.FrozenReason = "关键信息变更待审核";
merchant.FrozenAt = now;
}
else if (merchant.Status == MerchantStatus.Rejected)
{
merchant.Status = MerchantStatus.Pending;
merchant.IsFrozen = false;
merchant.FrozenReason = null;
merchant.FrozenAt = null;
}
// 5. 持久化日志与数据
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
foreach (var log in changes)
{
await merchantRepository.AddChangeLogAsync(log, cancellationToken);
}
if (requiresReview)
{
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewPendingReApproval,
Title = "关键信息变更待审核",
Description = "关键信息修改后已进入待审核状态",
OperatorId = actorId,
OperatorName = actorName
}, cancellationToken);
}
try
{
await merchantRepository.SaveChangesAsync(cancellationToken);
}
catch (Exception exception) when (IsConcurrencyException(exception))
{
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
}
logger.LogInformation("更新商户 {MerchantId} - {Name}", merchant.Id, merchant.BrandName);
// 6. 返回更新结果
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
var tenant = await tenantRepository.FindByIdAsync(merchant.TenantId, cancellationToken);
var detail = MerchantMapping.ToDetailDto(merchant, tenant?.Name, MerchantMapping.ToStoreDtos(stores));
return new UpdateMerchantResultDto
{
Merchant = detail,
RequiresReview = requiresReview
};
void TrackChange(string fieldName, string? oldValue, string? newValue, bool isCritical)
{
var normalizedOld = NormalizeOptional(oldValue);
var normalizedNew = NormalizeOptional(newValue);
if (string.Equals(normalizedOld, normalizedNew, StringComparison.Ordinal))
{
return;
}
if (isCritical)
{
criticalChanged = true;
}
changes.Add(new MerchantChangeLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
FieldName = fieldName,
OldValue = normalizedOld,
NewValue = normalizedNew,
ChangedBy = actorId,
ChangedByName = actorName,
ChangeType = "Update"
});
}
}
private static MerchantDto MapToDto(Domain.Merchants.Entities.Merchant merchant) => new()
private static string NormalizeRequired(string? value, string fieldName)
{
Id = merchant.Id,
TenantId = merchant.TenantId,
BrandName = merchant.BrandName,
BrandAlias = merchant.BrandAlias,
LogoUrl = merchant.LogoUrl,
Category = merchant.Category,
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt,
CreatedAt = merchant.CreatedAt
};
if (string.IsNullOrWhiteSpace(value))
{
throw new BusinessException(ErrorCodes.ValidationFailed, $"{fieldName}不能为空");
}
return value.Trim();
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private string ResolveActorName()
=> currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
private static bool IsConcurrencyException(Exception exception)
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
}

View File

@@ -1,5 +1,6 @@
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Stores.Entities;
namespace TakeoutSaaS.Application.App.Merchants;
@@ -28,6 +29,53 @@ internal static class MerchantMapping
CreatedAt = merchant.CreatedAt
};
/// <summary>
/// 将商户实体映射为列表项 DTO。
/// </summary>
public static MerchantListItemDto ToListItemDto(Merchant merchant, string? tenantName, int storeCount) => new()
{
Id = merchant.Id,
TenantId = merchant.TenantId,
TenantName = tenantName,
Name = merchant.BrandName,
OperatingMode = merchant.OperatingMode,
LicenseNumber = merchant.BusinessLicenseNumber,
Status = merchant.Status,
IsFrozen = merchant.IsFrozen,
StoreCount = storeCount,
CreatedAt = merchant.CreatedAt,
UpdatedAt = merchant.UpdatedAt
};
/// <summary>
/// 将商户实体映射为详情 DTO。
/// </summary>
public static MerchantDetailDto ToDetailDto(Merchant merchant, string? tenantName, IReadOnlyList<StoreDto> stores) => new()
{
Id = merchant.Id,
TenantId = merchant.TenantId,
TenantName = tenantName,
Name = merchant.BrandName,
OperatingMode = merchant.OperatingMode,
LicenseNumber = merchant.BusinessLicenseNumber,
LegalRepresentative = merchant.LegalPerson,
RegisteredAddress = merchant.Address,
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
IsFrozen = merchant.IsFrozen,
FrozenReason = merchant.FrozenReason,
FrozenAt = merchant.FrozenAt,
ApprovedBy = merchant.ApprovedBy,
ApprovedAt = merchant.ApprovedAt,
Stores = stores,
RowVersion = merchant.RowVersion,
CreatedAt = merchant.CreatedAt,
CreatedBy = merchant.CreatedBy,
UpdatedAt = merchant.UpdatedAt,
UpdatedBy = merchant.UpdatedBy
};
/// <summary>
/// 将商户证照实体映射为 DTO。
/// </summary>
@@ -76,12 +124,42 @@ internal static class MerchantMapping
Id = log.Id,
MerchantId = log.MerchantId,
Action = log.Action,
OperatorId = log.OperatorId,
Title = log.Title,
Description = log.Description,
OperatorName = log.OperatorName,
IpAddress = log.IpAddress,
CreatedAt = log.CreatedAt
};
/// <summary>
/// 将商户变更日志实体映射为 DTO。
/// </summary>
public static MerchantChangeLogDto ToDto(MerchantChangeLog log) => new()
{
Id = log.Id,
FieldName = log.FieldName,
OldValue = log.OldValue,
NewValue = log.NewValue,
ChangedBy = log.ChangedBy,
ChangedByName = log.ChangedByName,
ChangedAt = log.CreatedAt,
ChangeReason = log.ChangeReason
};
/// <summary>
/// 将门店实体映射为 DTO。
/// </summary>
public static StoreDto ToStoreDto(Store store) => new()
{
Id = store.Id,
Name = store.Name,
LicenseNumber = store.BusinessLicenseNumber,
ContactPhone = store.Phone,
Address = store.Address,
Status = store.Status
};
/// <summary>
/// 将商户分类实体映射为 DTO。
/// </summary>
@@ -119,4 +197,10 @@ internal static class MerchantMapping
/// <returns>分类 DTO 列表。</returns>
public static IReadOnlyList<MerchantCategoryDto> ToCategoryDtos(IEnumerable<MerchantCategory> categories)
=> categories.Select(ToDto).ToList();
/// <summary>
/// 将门店集合映射为 DTO 集合。
/// </summary>
public static IReadOnlyList<StoreDto> ToStoreDtos(IEnumerable<Store> stores)
=> stores.Select(ToStoreDto).ToList();
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 导出商户 PDF 查询。
/// </summary>
public sealed record ExportMerchantPdfQuery(long MerchantId) : IRequest<byte[]>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 商户审核历史查询。
/// </summary>
public sealed record GetMerchantAuditHistoryQuery(long MerchantId) : IRequest<IReadOnlyList<MerchantAuditLogDto>>;

View File

@@ -0,0 +1,10 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 获取商户变更历史。
/// </summary>
public sealed record GetMerchantChangeHistoryQuery(long MerchantId, string? FieldName = null)
: IRequest<IReadOnlyList<MerchantChangeLogDto>>;

View File

@@ -0,0 +1,53 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 商户列表查询。
/// </summary>
public sealed class GetMerchantListQuery : IRequest<PagedResult<MerchantListItemDto>>
{
/// <summary>
/// 关键词(商户名称/营业执照号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 状态过滤。
/// </summary>
public MerchantStatus? Status { get; init; }
/// <summary>
/// 经营模式过滤。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 租户过滤(管理员可用)。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段createdAt/updatedAt/name/status
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 排序方向asc/desc
/// </summary>
public string? SortOrder { get; init; }
}

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 获取商户审核领取信息查询。
/// </summary>
public sealed record GetMerchantReviewClaimQuery(long MerchantId) : IRequest<ClaimInfoDto?>;

View File

@@ -0,0 +1,37 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 待审核商户列表查询。
/// </summary>
public sealed class GetPendingReviewListQuery : IRequest<PagedResult<MerchantReviewListItemDto>>
{
/// <summary>
/// 关键词(商户名称/营业执照号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 经营模式筛选。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 租户筛选。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Merchants.Commands;
namespace TakeoutSaaS.Application.App.Merchants.Validators;
/// <summary>
/// 商户审核命令验证器。
/// </summary>
public sealed class ReviewMerchantValidator : AbstractValidator<ReviewMerchantCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReviewMerchantValidator()
{
RuleFor(x => x.MerchantId).GreaterThan(0);
RuleFor(x => x.Remarks)
.NotEmpty()
.When(x => !x.Approve);
RuleFor(x => x.Remarks)
.MaximumLength(500)
.When(x => !string.IsNullOrWhiteSpace(x.Remarks));
}
}

View File

@@ -14,11 +14,13 @@ public sealed class UpdateMerchantCommandValidator : AbstractValidator<UpdateMer
public UpdateMerchantCommandValidator()
{
RuleFor(x => x.MerchantId).GreaterThan(0);
RuleFor(x => x.BrandName).NotEmpty().MaximumLength(128);
RuleFor(x => x.BrandAlias).MaximumLength(64);
RuleFor(x => x.LogoUrl).MaximumLength(256);
RuleFor(x => x.Category).MaximumLength(64);
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.LicenseNumber).MaximumLength(64);
RuleFor(x => x.LegalRepresentative).MaximumLength(64);
RuleFor(x => x.RegisteredAddress).MaximumLength(256);
RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32);
RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
RuleFor(x => x.ContactEmail).EmailAddress().MaximumLength(128)
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
RuleFor(x => x.RowVersion).NotEmpty();
}
}

View File

@@ -1,6 +1,7 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Common.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
@@ -29,4 +30,9 @@ public sealed record ReviewTenantCommand : IRequest<TenantDto>
/// 审核通过后续费时长(月)。
/// </summary>
public int? RenewMonths { get; init; }
/// <summary>
/// 经营模式(审核通过时必填)。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
}

View File

@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
@@ -55,6 +56,11 @@ public sealed class TenantDto
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; }
/// <summary>
/// 经营模式。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 当前套餐 ID。
/// </summary>

View File

@@ -1,6 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
@@ -14,6 +17,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// </summary>
public sealed class ReviewTenantCommandHandler(
ITenantRepository tenantRepository,
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewTenantCommand, TenantDto>
{
@@ -53,19 +57,25 @@ public sealed class ReviewTenantCommandHandler(
// 4. 更新租户与订阅状态
if (request.Approve)
{
if (!request.OperatingMode.HasValue)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "审核通过时必须选择经营模式");
}
var renewMonths = request.RenewMonths ?? 0;
if (renewMonths <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "续费时长必须为正整数(月)");
}
var now = DateTime.UtcNow;
verification.Status = TenantVerificationStatus.Approved;
tenant.Status = TenantStatus.Active;
tenant.OperatingMode = request.OperatingMode;
if (subscription != null)
{
subscription.Status = SubscriptionStatus.Active;
var now = DateTime.UtcNow;
if (subscription.EffectiveFrom == default || subscription.EffectiveFrom > now)
{
subscription.EffectiveFrom = now;
@@ -92,6 +102,69 @@ public sealed class ReviewTenantCommandHandler(
{
throw new BusinessException(ErrorCodes.BadRequest, "订阅不存在,无法续费");
}
var existingMerchant = await merchantRepository.FindByTenantIdAsync(tenant.Id, cancellationToken);
if (existingMerchant == null)
{
var merchant = new Merchant
{
TenantId = tenant.Id,
BrandName = tenant.Name,
BrandAlias = tenant.ShortName,
Category = tenant.Industry,
ContactPhone = tenant.ContactPhone ?? string.Empty,
ContactEmail = tenant.ContactEmail,
BusinessLicenseNumber = verification.BusinessLicenseNumber,
BusinessLicenseImageUrl = verification.BusinessLicenseUrl,
LegalPerson = verification.LegalPersonName,
Province = tenant.Province,
City = tenant.City,
Address = tenant.Address,
Status = MerchantStatus.Approved,
OperatingMode = request.OperatingMode,
ApprovedAt = now,
ApprovedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
JoinedAt = now,
LastReviewedAt = now,
LastReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
IsFrozen = false
};
await merchantRepository.AddMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenant.Id,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewApproved,
Title = "商户审核通过",
Description = request.Reason,
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
}
else
{
existingMerchant.Status = MerchantStatus.Approved;
existingMerchant.OperatingMode = request.OperatingMode;
existingMerchant.ApprovedAt = now;
existingMerchant.ApprovedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
existingMerchant.LastReviewedAt = now;
existingMerchant.LastReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
existingMerchant.IsFrozen = false;
existingMerchant.FrozenReason = null;
existingMerchant.FrozenAt = null;
await merchantRepository.UpdateMerchantAsync(existingMerchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenant.Id,
MerchantId = existingMerchant.Id,
Action = MerchantAuditAction.ReviewApproved,
Title = "商户审核通过",
Description = request.Reason,
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
}
}
else
{
@@ -141,6 +214,7 @@ public sealed class ReviewTenantCommandHandler(
// 8. 保存并返回 DTO
await tenantRepository.SaveChangesAsync(cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
return TenantMapping.ToDto(tenant, subscription, verification);
}

View File

@@ -28,6 +28,7 @@ internal static class TenantMapping
ContactEmail = tenant.ContactEmail,
Status = tenant.Status,
VerificationStatus = verification?.Status ?? Domain.Tenants.Enums.TenantVerificationStatus.Draft,
OperatingMode = tenant.OperatingMode,
CurrentPackageId = subscription?.TenantPackageId,
EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom,
EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo,

View File

@@ -0,0 +1,28 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Tenants.Commands;
namespace TakeoutSaaS.Application.App.Tenants.Validators;
/// <summary>
/// 租户审核命令验证器。
/// </summary>
public sealed class ReviewTenantValidator : AbstractValidator<ReviewTenantCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReviewTenantValidator()
{
RuleFor(x => x.TenantId).GreaterThan(0);
RuleFor(x => x.Reason)
.NotEmpty()
.When(x => !x.Approve);
RuleFor(x => x.OperatingMode)
.NotNull()
.When(x => x.Approve);
RuleFor(x => x.RenewMonths)
.NotNull()
.GreaterThan(0)
.When(x => x.Approve);
}
}

View File

@@ -8,5 +8,5 @@ public static class MenuPolicy
/// <summary>
/// 是否允许维护菜单(创建/更新/删除)。
/// </summary>
public const bool CanMaintainMenus = false;
public static bool CanMaintainMenus { get; } = false;
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Domain.Common.Enums;
/// <summary>
/// 经营模式。
/// </summary>
public enum OperatingMode
{
/// <summary>
/// 同一主体。
/// </summary>
SameEntity = 1,
/// <summary>
/// 不同主体。
/// </summary>
DifferentEntity = 2
}

View File

@@ -1,3 +1,4 @@
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
@@ -103,6 +104,31 @@ public sealed class Merchant : MultiTenantEntityBase
/// </summary>
public MerchantStatus Status { get; set; } = MerchantStatus.Pending;
/// <summary>
/// 经营模式(同一主体/不同主体)。
/// </summary>
public OperatingMode? OperatingMode { get; set; }
/// <summary>
/// 是否冻结业务。
/// </summary>
public bool IsFrozen { get; set; }
/// <summary>
/// 冻结原因。
/// </summary>
public string? FrozenReason { get; set; }
/// <summary>
/// 冻结时间。
/// </summary>
public DateTime? FrozenAt { get; set; }
/// <summary>
/// 最近一次审核人。
/// </summary>
public long? LastReviewedBy { get; set; }
/// <summary>
/// 审核备注或驳回原因。
/// </summary>
@@ -117,4 +143,39 @@ public sealed class Merchant : MultiTenantEntityBase
/// 最近一次审核时间。
/// </summary>
public DateTime? LastReviewedAt { get; set; }
/// <summary>
/// 审核通过人。
/// </summary>
public long? ApprovedBy { get; set; }
/// <summary>
/// 审核通过时间。
/// </summary>
public DateTime? ApprovedAt { get; set; }
/// <summary>
/// 当前领取人。
/// </summary>
public long? ClaimedBy { get; set; }
/// <summary>
/// 当前领取人姓名。
/// </summary>
public string? ClaimedByName { get; set; }
/// <summary>
/// 领取时间。
/// </summary>
public DateTime? ClaimedAt { get; set; }
/// <summary>
/// 领取过期时间。
/// </summary>
public DateTime? ClaimExpiresAt { get; set; }
/// <summary>
/// 并发控制版本。
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

View File

@@ -37,4 +37,9 @@ public sealed class MerchantAuditLog : MultiTenantEntityBase
/// 操作人名称。
/// </summary>
public string? OperatorName { get; set; }
/// <summary>
/// 操作 IP。
/// </summary>
public string? IpAddress { get; set; }
}

View File

@@ -0,0 +1,49 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Merchants.Entities;
/// <summary>
/// 商户变更日志。
/// </summary>
public sealed class MerchantChangeLog : MultiTenantEntityBase
{
/// <summary>
/// 商户标识。
/// </summary>
public long MerchantId { get; set; }
/// <summary>
/// 变更字段名。
/// </summary>
public string FieldName { get; set; } = string.Empty;
/// <summary>
/// 变更前值。
/// </summary>
public string? OldValue { get; set; }
/// <summary>
/// 变更后值。
/// </summary>
public string? NewValue { get; set; }
/// <summary>
/// 变更类型。
/// </summary>
public string ChangeType { get; set; } = "Update";
/// <summary>
/// 变更人 ID。
/// </summary>
public long? ChangedBy { get; set; }
/// <summary>
/// 变更人名称。
/// </summary>
public string? ChangedByName { get; set; }
/// <summary>
/// 变更原因。
/// </summary>
public string? ChangeReason { get; set; }
}

View File

@@ -33,5 +33,40 @@ public enum MerchantAuditAction
/// <summary>
/// 商户审核结果。
/// </summary>
MerchantReviewed = 5
MerchantReviewed = 5,
/// <summary>
/// 领取审核。
/// </summary>
ReviewClaimed = 6,
/// <summary>
/// 释放审核。
/// </summary>
ReviewReleased = 7,
/// <summary>
/// 审核通过。
/// </summary>
ReviewApproved = 8,
/// <summary>
/// 审核驳回。
/// </summary>
ReviewRejected = 9,
/// <summary>
/// 撤销审核。
/// </summary>
ReviewRevoked = 10,
/// <summary>
/// 关键信息变更进入待审核。
/// </summary>
ReviewPendingReApproval = 11,
/// <summary>
/// 强制接管审核。
/// </summary>
ReviewForceClaimed = 12
}

View File

@@ -1,3 +1,4 @@
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
@@ -17,6 +18,22 @@ public interface IMerchantRepository
/// <returns>商户实体或 null。</returns>
Task<Merchant?> FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 依据标识获取商户(忽略租户过滤)。
/// </summary>
/// <param name="merchantId">商户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>商户实体或 null。</returns>
Task<Merchant?> FindByIdAsync(long merchantId, CancellationToken cancellationToken = default);
/// <summary>
/// 依据租户标识获取商户(忽略租户过滤)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>商户实体或 null。</returns>
Task<Merchant?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按状态筛选商户列表。
/// </summary>
@@ -26,6 +43,22 @@ public interface IMerchantRepository
/// <returns>商户集合。</returns>
Task<IReadOnlyList<Merchant>> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default);
/// <summary>
/// 按条件筛选商户列表(支持跨租户)。
/// </summary>
/// <param name="tenantId">租户 ID为 null 时查询全部租户。</param>
/// <param name="status">状态过滤。</param>
/// <param name="operatingMode">经营模式过滤。</param>
/// <param name="keyword">关键词过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>商户集合。</returns>
Task<IReadOnlyList<Merchant>> SearchAsync(
long? tenantId,
MerchantStatus? status,
OperatingMode? operatingMode,
string? keyword,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定商户的员工列表。
/// </summary>
@@ -168,6 +201,14 @@ public interface IMerchantRepository
/// <returns>异步任务。</returns>
Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default);
/// <summary>
/// 记录变更日志。
/// </summary>
/// <param name="log">变更日志实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task AddChangeLogAsync(MerchantChangeLog log, CancellationToken cancellationToken = default);
/// <summary>
/// 获取审核日志。
/// </summary>
@@ -176,4 +217,18 @@ public interface IMerchantRepository
/// <param name="cancellationToken">取消标记。</param>
/// <returns>审核日志列表。</returns>
Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取变更日志。
/// </summary>
/// <param name="merchantId">商户 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="fieldName">字段过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>变更日志列表。</returns>
Task<IReadOnlyList<MerchantChangeLog>> GetChangeLogsAsync(
long merchantId,
long tenantId,
string? fieldName = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,26 @@
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Stores.Entities;
namespace TakeoutSaaS.Domain.Merchants.Services;
/// <summary>
/// 商户导出服务接口。
/// </summary>
public interface IMerchantExportService
{
/// <summary>
/// 导出为 PDF。
/// </summary>
/// <param name="merchant">商户主体。</param>
/// <param name="tenantName">租户名称。</param>
/// <param name="stores">门店列表。</param>
/// <param name="auditLogs">审核历史。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>PDF 字节数组。</returns>
Task<byte[]> ExportToPdfAsync(
Merchant merchant,
string? tenantName,
IReadOnlyList<Store> stores,
IReadOnlyList<MerchantAuditLog> auditLogs,
CancellationToken cancellationToken = default);
}

View File

@@ -33,6 +33,26 @@ public sealed class Store : MultiTenantEntityBase
/// </summary>
public string? ManagerName { get; set; }
/// <summary>
/// 门店营业执照号(主体不一致模式使用)。
/// </summary>
public string? BusinessLicenseNumber { get; set; }
/// <summary>
/// 门店法人(主体不一致模式使用)。
/// </summary>
public string? LegalRepresentative { get; set; }
/// <summary>
/// 门店注册地址(主体不一致模式使用)。
/// </summary>
public string? RegisteredAddress { get; set; }
/// <summary>
/// 门店营业执照图片地址(主体不一致模式使用)。
/// </summary>
public string? BusinessLicenseImageUrl { get; set; }
/// <summary>
/// 门店当前运营状态。
/// </summary>

View File

@@ -13,11 +13,21 @@ public interface IStoreRepository
/// </summary>
Task<Store?> FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定商户的门店列表。
/// </summary>
Task<IReadOnlyList<Store>> GetByMerchantIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按租户筛选门店列表。
/// </summary>
Task<IReadOnlyList<Store>> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定商户集合的门店数量。
/// </summary>
Task<Dictionary<long, int>> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default);
/// <summary>
/// 获取门店营业时段。
/// </summary>

View File

@@ -1,3 +1,4 @@
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
@@ -93,6 +94,11 @@ public sealed class Tenant : AuditableEntityBase
/// </summary>
public TenantStatus Status { get; set; } = TenantStatus.PendingReview;
/// <summary>
/// 经营模式(同一主体/不同主体)。
/// </summary>
public OperatingMode? OperatingMode { get; set; }
/// <summary>
/// 服务生效时间UTC
/// </summary>

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Merchants.Services;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Domain.Products.Repositories;
@@ -63,6 +64,7 @@ public static class AppServiceCollectionExtensions
// 1. 账单领域/导出服务
services.AddScoped<IBillingDomainService, BillingDomainService>();
services.AddScoped<IBillingExportService, BillingExportService>();
services.AddScoped<IMerchantExportService, MerchantExportService>();
services.AddOptions<AppSeedOptions>()
.Bind(configuration.GetSection(AppSeedOptions.SectionName))

View File

@@ -469,6 +469,7 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Industry).HasMaxLength(64);
builder.Property(x => x.LogoUrl).HasColumnType("text");
builder.Property(x => x.Remarks).HasMaxLength(512);
builder.Property(x => x.OperatingMode).HasConversion<int>();
builder.HasIndex(x => x.Code).IsUnique();
builder.HasIndex(x => x.ContactPhone).IsUnique();
}
@@ -533,7 +534,17 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.District).HasMaxLength(64);
builder.Property(x => x.Address).HasMaxLength(256);
builder.Property(x => x.ReviewRemarks).HasMaxLength(512);
builder.Property(x => x.OperatingMode).HasConversion<int>();
builder.Property(x => x.IsFrozen).HasDefaultValue(false);
builder.Property(x => x.FrozenReason).HasMaxLength(500);
builder.Property(x => x.ClaimedByName).HasMaxLength(100);
builder.Property(x => x.RowVersion)
.IsRowVersion()
.IsConcurrencyToken()
.HasColumnType("bytea");
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Status });
builder.HasIndex(x => x.ClaimedBy);
}
private static void ConfigureStore(EntityTypeBuilder<Store> builder)
@@ -544,6 +555,10 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
builder.Property(x => x.Phone).HasMaxLength(32);
builder.Property(x => x.ManagerName).HasMaxLength(64);
builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(50);
builder.Property(x => x.LegalRepresentative).HasMaxLength(100);
builder.Property(x => x.RegisteredAddress).HasMaxLength(500);
builder.Property(x => x.BusinessLicenseImageUrl).HasMaxLength(500);
builder.Property(x => x.Province).HasMaxLength(64);
builder.Property(x => x.City).HasMaxLength(64);
builder.Property(x => x.District).HasMaxLength(64);
@@ -553,6 +568,9 @@ public sealed class TakeoutAppDbContext(
builder.Property(x => x.DeliveryRadiusKm).HasPrecision(6, 2);
builder.HasIndex(x => new { x.TenantId, x.MerchantId });
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
builder.HasIndex(x => new { x.MerchantId, x.BusinessLicenseNumber })
.IsUnique()
.HasFilter("\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3");
}
private static void ConfigureProductCategory(EntityTypeBuilder<ProductCategory> builder)

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
@@ -24,6 +25,26 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<Merchant?> FindByIdAsync(long merchantId, CancellationToken cancellationToken = default)
{
return context.Merchants
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.Id == merchantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<Merchant?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.Merchants
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Merchant>> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default)
{
@@ -208,9 +229,79 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
return await logsContext.MerchantAuditLogs
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Merchant>> SearchAsync(
long? tenantId,
MerchantStatus? status,
OperatingMode? operatingMode,
string? keyword,
CancellationToken cancellationToken = default)
{
var query = context.Merchants
.IgnoreQueryFilters()
.AsNoTracking()
.AsQueryable();
if (tenantId.HasValue && tenantId.Value > 0)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (operatingMode.HasValue)
{
query = query.Where(x => x.OperatingMode == operatingMode.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x =>
EF.Functions.ILike(x.BrandName, $"%{normalized}%") ||
EF.Functions.ILike(x.BusinessLicenseNumber ?? string.Empty, $"%{normalized}%"));
}
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddChangeLogAsync(MerchantChangeLog log, CancellationToken cancellationToken = default)
{
return logsContext.MerchantChangeLogs.AddAsync(log, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantChangeLog>> GetChangeLogsAsync(
long merchantId,
long tenantId,
string? fieldName = null,
CancellationToken cancellationToken = default)
{
var query = logsContext.MerchantChangeLogs
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId);
if (!string.IsNullOrWhiteSpace(fieldName))
{
query = query.Where(x => x.FieldName == fieldName);
}
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
}

View File

@@ -25,6 +25,16 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Store>> GetByMerchantIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
return await context.Stores
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Store>> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default)
{
@@ -44,6 +54,31 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos
return stores;
}
/// <inheritdoc />
public async Task<Dictionary<long, int>> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default)
{
if (merchantIds.Count == 0)
{
return new Dictionary<long, int>();
}
var query = context.Stores.AsNoTracking();
if (!tenantId.HasValue || tenantId.Value <= 0)
{
query = query.IgnoreQueryFilters();
}
else
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
return await query
.Where(x => merchantIds.Contains(x.MerchantId))
.GroupBy(x => x.MerchantId)
.Select(group => new { group.Key, Count = group.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{

View File

@@ -0,0 +1,152 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Globalization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Services;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 商户导出服务实现PDF
/// </summary>
public sealed class MerchantExportService : IMerchantExportService
{
public MerchantExportService()
{
QuestPDF.Settings.License = LicenseType.Community;
}
/// <inheritdoc />
public Task<byte[]> ExportToPdfAsync(
Merchant merchant,
string? tenantName,
IReadOnlyList<Store> stores,
IReadOnlyList<MerchantAuditLog> auditLogs,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(merchant);
var safeStores = stores ?? Array.Empty<Store>();
var safeAuditLogs = auditLogs ?? Array.Empty<MerchantAuditLog>();
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(24);
page.DefaultTextStyle(x => x.FontSize(10));
page.Content().Column(column =>
{
column.Spacing(10);
column.Item().Text("Merchant Export").FontSize(16).SemiBold();
column.Item().Element(section => BuildBasicSection(section, merchant, tenantName));
column.Item().Element(section => BuildStoresSection(section, safeStores, cancellationToken));
column.Item().Element(section => BuildAuditSection(section, safeAuditLogs, cancellationToken));
});
});
});
return Task.FromResult(document.GeneratePdf());
}
private static void BuildBasicSection(IContainer container, Merchant merchant, string? tenantName)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text("Basic Information").SemiBold();
column.Item().Text($"Merchant: {merchant.BrandName}");
column.Item().Text($"Tenant: {tenantName ?? "-"} (ID: {merchant.TenantId})");
column.Item().Text($"Operating Mode: {ResolveOperatingMode(merchant.OperatingMode)}");
column.Item().Text($"Status: {merchant.Status}");
column.Item().Text($"Frozen: {(merchant.IsFrozen ? "Yes" : "No")}");
column.Item().Text($"License Number: {merchant.BusinessLicenseNumber ?? "-"}");
column.Item().Text($"Legal Representative: {merchant.LegalPerson ?? "-"}");
column.Item().Text($"Registered Address: {merchant.Address ?? "-"}");
column.Item().Text($"Contact Phone: {merchant.ContactPhone}");
column.Item().Text($"Contact Email: {merchant.ContactEmail ?? "-"}");
column.Item().Text($"Approved At: {FormatDateTime(merchant.ApprovedAt)}");
column.Item().Text($"Approved By: {merchant.ApprovedBy?.ToString() ?? "-"}");
});
}
private static void BuildStoresSection(IContainer container, IReadOnlyList<Store> stores, CancellationToken cancellationToken)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text("Stores").SemiBold();
if (stores.Count == 0)
{
column.Item().Text("No stores.");
return;
}
for (var i = 0; i < stores.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var store = stores[i];
column.Item().Text($"{i + 1}. {store.Name} | {ResolveStoreStatus(store.Status)} | {store.Address ?? "-"} | {store.Phone ?? "-"}");
}
});
}
private static void BuildAuditSection(IContainer container, IReadOnlyList<MerchantAuditLog> auditLogs, CancellationToken cancellationToken)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text("Audit History").SemiBold();
if (auditLogs.Count == 0)
{
column.Item().Text("No audit records.");
return;
}
for (var i = 0; i < auditLogs.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var log = auditLogs[i];
var title = string.IsNullOrWhiteSpace(log.Title) ? log.Action.ToString() : log.Title;
column.Item().Text($"{i + 1}. {title} | {log.OperatorName ?? "-"} | {FormatDateTime(log.CreatedAt)}");
if (!string.IsNullOrWhiteSpace(log.Description))
{
column.Item().Text($" {log.Description}");
}
}
});
}
private static string ResolveOperatingMode(OperatingMode? mode)
=> mode switch
{
OperatingMode.SameEntity => "SameEntity",
OperatingMode.DifferentEntity => "DifferentEntity",
_ => "-"
};
private static string ResolveStoreStatus(StoreStatus status)
=> status switch
{
StoreStatus.Closed => "Closed",
StoreStatus.Preparing => "Preparing",
StoreStatus.Operating => "Operating",
StoreStatus.Suspended => "Suspended",
_ => status.ToString()
};
private static string FormatDateTime(DateTime? value)
=> value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : "-";
}

View File

@@ -30,6 +30,11 @@ public sealed class TakeoutLogsDbContext(
/// </summary>
public DbSet<MerchantAuditLog> MerchantAuditLogs => Set<MerchantAuditLog>();
/// <summary>
/// 商户变更日志集合。
/// </summary>
public DbSet<MerchantChangeLog> MerchantChangeLogs => Set<MerchantChangeLog>();
/// <summary>
/// 运营操作日志集合。
/// </summary>
@@ -54,6 +59,7 @@ public sealed class TakeoutLogsDbContext(
base.OnModelCreating(modelBuilder);
ConfigureTenantAuditLog(modelBuilder.Entity<TenantAuditLog>());
ConfigureMerchantAuditLog(modelBuilder.Entity<MerchantAuditLog>());
ConfigureMerchantChangeLog(modelBuilder.Entity<MerchantChangeLog>());
ConfigureOperationLog(modelBuilder.Entity<OperationLog>());
ConfigureOperationLogInboxMessage(modelBuilder.Entity<OperationLogInboxMessage>());
ConfigureMemberGrowthLog(modelBuilder.Entity<MemberGrowthLog>());
@@ -75,10 +81,29 @@ public sealed class TakeoutLogsDbContext(
builder.ToTable("merchant_audit_logs");
builder.HasKey(x => x.Id);
builder.Property(x => x.MerchantId).IsRequired();
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
builder.Property(x => x.Action).HasConversion<int>().IsRequired();
builder.Property(x => x.Title).HasMaxLength(200).IsRequired();
builder.Property(x => x.Description).HasMaxLength(1024);
builder.Property(x => x.OperatorName).HasMaxLength(64);
builder.Property(x => x.OperatorName).HasMaxLength(100);
builder.Property(x => x.IpAddress).HasMaxLength(50);
builder.HasIndex(x => new { x.TenantId, x.MerchantId });
builder.HasIndex(x => new { x.MerchantId, x.CreatedAt });
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
}
private static void ConfigureMerchantChangeLog(EntityTypeBuilder<MerchantChangeLog> builder)
{
builder.ToTable("merchant_change_logs");
builder.HasKey(x => x.Id);
builder.Property(x => x.MerchantId).IsRequired();
builder.Property(x => x.FieldName).HasMaxLength(100).IsRequired();
builder.Property(x => x.OldValue).HasColumnType("text");
builder.Property(x => x.NewValue).HasColumnType("text");
builder.Property(x => x.ChangeType).HasMaxLength(20).IsRequired();
builder.Property(x => x.ChangedByName).HasMaxLength(100);
builder.Property(x => x.ChangeReason).HasMaxLength(512);
builder.HasIndex(x => new { x.MerchantId, x.CreatedAt });
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
}
private static void ConfigureOperationLog(EntityTypeBuilder<OperationLog> builder)

View File

@@ -0,0 +1,244 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMerchantManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OperatingMode",
table: "tenants",
type: "integer",
nullable: true,
comment: "经营模式(同一主体/不同主体)。");
migrationBuilder.AddColumn<string>(
name: "BusinessLicenseImageUrl",
table: "stores",
type: "character varying(500)",
maxLength: 500,
nullable: true,
comment: "门店营业执照图片地址(主体不一致模式使用)。");
migrationBuilder.AddColumn<string>(
name: "BusinessLicenseNumber",
table: "stores",
type: "character varying(50)",
maxLength: 50,
nullable: true,
comment: "门店营业执照号(主体不一致模式使用)。");
migrationBuilder.AddColumn<string>(
name: "LegalRepresentative",
table: "stores",
type: "character varying(100)",
maxLength: 100,
nullable: true,
comment: "门店法人(主体不一致模式使用)。");
migrationBuilder.AddColumn<string>(
name: "RegisteredAddress",
table: "stores",
type: "character varying(500)",
maxLength: 500,
nullable: true,
comment: "门店注册地址(主体不一致模式使用)。");
migrationBuilder.AddColumn<DateTime>(
name: "ApprovedAt",
table: "merchants",
type: "timestamp with time zone",
nullable: true,
comment: "审核通过时间。");
migrationBuilder.AddColumn<long>(
name: "ApprovedBy",
table: "merchants",
type: "bigint",
nullable: true,
comment: "审核通过人。");
migrationBuilder.AddColumn<DateTime>(
name: "ClaimExpiresAt",
table: "merchants",
type: "timestamp with time zone",
nullable: true,
comment: "领取过期时间。");
migrationBuilder.AddColumn<DateTime>(
name: "ClaimedAt",
table: "merchants",
type: "timestamp with time zone",
nullable: true,
comment: "领取时间。");
migrationBuilder.AddColumn<long>(
name: "ClaimedBy",
table: "merchants",
type: "bigint",
nullable: true,
comment: "当前领取人。");
migrationBuilder.AddColumn<string>(
name: "ClaimedByName",
table: "merchants",
type: "character varying(100)",
maxLength: 100,
nullable: true,
comment: "当前领取人姓名。");
migrationBuilder.AddColumn<DateTime>(
name: "FrozenAt",
table: "merchants",
type: "timestamp with time zone",
nullable: true,
comment: "冻结时间。");
migrationBuilder.AddColumn<string>(
name: "FrozenReason",
table: "merchants",
type: "character varying(500)",
maxLength: 500,
nullable: true,
comment: "冻结原因。");
migrationBuilder.AddColumn<bool>(
name: "IsFrozen",
table: "merchants",
type: "boolean",
nullable: false,
defaultValue: false,
comment: "是否冻结业务。");
migrationBuilder.AddColumn<long>(
name: "LastReviewedBy",
table: "merchants",
type: "bigint",
nullable: true,
comment: "最近一次审核人。");
migrationBuilder.AddColumn<int>(
name: "OperatingMode",
table: "merchants",
type: "integer",
nullable: true,
comment: "经营模式(同一主体/不同主体)。");
migrationBuilder.AddColumn<byte[]>(
name: "RowVersion",
table: "merchants",
type: "bytea",
rowVersion: true,
nullable: false,
defaultValue: new byte[0],
comment: "并发控制版本。");
migrationBuilder.CreateIndex(
name: "IX_stores_MerchantId_BusinessLicenseNumber",
table: "stores",
columns: new[] { "MerchantId", "BusinessLicenseNumber" },
unique: true,
filter: "\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3");
migrationBuilder.CreateIndex(
name: "IX_merchants_ClaimedBy",
table: "merchants",
column: "ClaimedBy");
migrationBuilder.CreateIndex(
name: "IX_merchants_TenantId_Status",
table: "merchants",
columns: new[] { "TenantId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_stores_MerchantId_BusinessLicenseNumber",
table: "stores");
migrationBuilder.DropIndex(
name: "IX_merchants_ClaimedBy",
table: "merchants");
migrationBuilder.DropIndex(
name: "IX_merchants_TenantId_Status",
table: "merchants");
migrationBuilder.DropColumn(
name: "OperatingMode",
table: "tenants");
migrationBuilder.DropColumn(
name: "BusinessLicenseImageUrl",
table: "stores");
migrationBuilder.DropColumn(
name: "BusinessLicenseNumber",
table: "stores");
migrationBuilder.DropColumn(
name: "LegalRepresentative",
table: "stores");
migrationBuilder.DropColumn(
name: "RegisteredAddress",
table: "stores");
migrationBuilder.DropColumn(
name: "ApprovedAt",
table: "merchants");
migrationBuilder.DropColumn(
name: "ApprovedBy",
table: "merchants");
migrationBuilder.DropColumn(
name: "ClaimExpiresAt",
table: "merchants");
migrationBuilder.DropColumn(
name: "ClaimedAt",
table: "merchants");
migrationBuilder.DropColumn(
name: "ClaimedBy",
table: "merchants");
migrationBuilder.DropColumn(
name: "ClaimedByName",
table: "merchants");
migrationBuilder.DropColumn(
name: "FrozenAt",
table: "merchants");
migrationBuilder.DropColumn(
name: "FrozenReason",
table: "merchants");
migrationBuilder.DropColumn(
name: "IsFrozen",
table: "merchants");
migrationBuilder.DropColumn(
name: "LastReviewedBy",
table: "merchants");
migrationBuilder.DropColumn(
name: "OperatingMode",
table: "merchants");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "merchants");
}
}
}

View File

@@ -0,0 +1,454 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TakeoutSaaS.Infrastructure.Logs.Persistence;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
{
[DbContext(typeof(TakeoutLogsDbContext))]
[Migration("20251229071940_AddMerchantManagementLogs")]
partial class AddMerchantManagementLogs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberGrowthLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("ChangeValue")
.HasColumnType("integer")
.HasComment("变动数量。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<int>("CurrentValue")
.HasColumnType("integer")
.HasComment("当前成长值。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<long>("MemberId")
.HasColumnType("bigint")
.HasComment("会员标识。");
b.Property<string>("Notes")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasComment("备注。");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone")
.HasComment("发生时间。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId", "MemberId", "OccurredAt");
b.ToTable("member_growth_logs", null, t =>
{
t.HasComment("成长值变动日志。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantAuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("Action")
.HasColumnType("integer")
.HasComment("动作类型。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasComment("详情描述。");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasComment("操作 IP。");
b.Property<long>("MerchantId")
.HasColumnType("bigint")
.HasComment("商户标识。");
b.Property<long?>("OperatorId")
.HasColumnType("bigint")
.HasComment("操作人 ID。");
b.Property<string>("OperatorName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("操作人名称。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasComment("标题。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("MerchantId", "CreatedAt");
b.HasIndex("TenantId", "CreatedAt");
b.HasIndex("TenantId", "MerchantId");
b.ToTable("merchant_audit_logs", null, t =>
{
t.HasComment("商户入驻审核日志。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantChangeLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ChangeReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("变更原因。");
b.Property<string>("ChangeType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasComment("变更类型。");
b.Property<long?>("ChangedBy")
.HasColumnType("bigint")
.HasComment("变更人 ID。");
b.Property<string>("ChangedByName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("变更人名称。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("变更字段名。");
b.Property<long>("MerchantId")
.HasColumnType("bigint")
.HasComment("商户标识。");
b.Property<string>("NewValue")
.HasColumnType("text")
.HasComment("变更后值。");
b.Property<string>("OldValue")
.HasColumnType("text")
.HasComment("变更前值。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("MerchantId", "CreatedAt");
b.HasIndex("TenantId", "CreatedAt");
b.ToTable("merchant_change_logs", null, t =>
{
t.HasComment("商户变更日志。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.OperationLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("OperationType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("操作类型BatchExtend, BatchRemind, StatusChange 等。");
b.Property<string>("OperatorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("操作人ID。");
b.Property<string>("OperatorName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("操作人名称。");
b.Property<string>("Parameters")
.HasColumnType("text")
.HasComment("操作参数JSON。");
b.Property<string>("Result")
.HasColumnType("text")
.HasComment("操作结果JSON。");
b.Property<bool>("Success")
.HasColumnType("boolean")
.HasComment("是否成功。");
b.Property<string>("TargetIds")
.HasColumnType("text")
.HasComment("目标ID列表JSON。");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("目标类型Subscription, Bill 等。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("OperationType", "CreatedAt");
b.ToTable("operation_logs", null, t =>
{
t.HasComment("运营操作日志。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantAuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<int>("Action")
.HasColumnType("integer")
.HasComment("操作类型。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<int?>("CurrentStatus")
.HasColumnType("integer")
.HasComment("新状态。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("Description")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasComment("详细描述。");
b.Property<long?>("OperatorId")
.HasColumnType("bigint")
.HasComment("操作人 ID。");
b.Property<string>("OperatorName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("操作人名称。");
b.Property<int?>("PreviousStatus")
.HasColumnType("integer")
.HasComment("原状态。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("关联的租户标识。");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasComment("日志标题。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("tenant_audit_logs", null, t =>
{
t.HasComment("租户运营审核日志。");
});
});
modelBuilder.Entity("TakeoutSaaS.Infrastructure.Logs.Persistence.OperationLogInboxMessage", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("MessageId")
.IsUnique();
b.ToTable("operation_log_inbox_messages", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
{
/// <inheritdoc />
public partial class AddMerchantManagementLogs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Title",
table: "merchant_audit_logs",
type: "character varying(200)",
maxLength: 200,
nullable: false,
comment: "标题。",
oldClrType: typeof(string),
oldType: "character varying(128)",
oldMaxLength: 128,
oldComment: "标题。");
migrationBuilder.AlterColumn<string>(
name: "OperatorName",
table: "merchant_audit_logs",
type: "character varying(100)",
maxLength: 100,
nullable: true,
comment: "操作人名称。",
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64,
oldNullable: true,
oldComment: "操作人名称。");
migrationBuilder.AddColumn<string>(
name: "IpAddress",
table: "merchant_audit_logs",
type: "character varying(50)",
maxLength: 50,
nullable: true,
comment: "操作 IP。");
migrationBuilder.CreateTable(
name: "merchant_change_logs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MerchantId = table.Column<long>(type: "bigint", nullable: false, comment: "商户标识。"),
FieldName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false, comment: "变更字段名。"),
OldValue = table.Column<string>(type: "text", nullable: true, comment: "变更前值。"),
NewValue = table.Column<string>(type: "text", nullable: true, comment: "变更后值。"),
ChangeType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, comment: "变更类型。"),
ChangedBy = table.Column<long>(type: "bigint", nullable: true, comment: "变更人 ID。"),
ChangedByName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true, comment: "变更人名称。"),
ChangeReason = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "变更原因。"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间UTC。"),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间UTC从未更新时为 null。"),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间UTC未删除时为 null。"),
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
},
constraints: table =>
{
table.PrimaryKey("PK_merchant_change_logs", x => x.Id);
},
comment: "商户变更日志。");
migrationBuilder.CreateIndex(
name: "IX_merchant_audit_logs_MerchantId_CreatedAt",
table: "merchant_audit_logs",
columns: new[] { "MerchantId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_merchant_audit_logs_TenantId_CreatedAt",
table: "merchant_audit_logs",
columns: new[] { "TenantId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_merchant_change_logs_MerchantId_CreatedAt",
table: "merchant_change_logs",
columns: new[] { "MerchantId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_merchant_change_logs_TenantId_CreatedAt",
table: "merchant_change_logs",
columns: new[] { "TenantId", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "merchant_change_logs");
migrationBuilder.DropIndex(
name: "IX_merchant_audit_logs_MerchantId_CreatedAt",
table: "merchant_audit_logs");
migrationBuilder.DropIndex(
name: "IX_merchant_audit_logs_TenantId_CreatedAt",
table: "merchant_audit_logs");
migrationBuilder.DropColumn(
name: "IpAddress",
table: "merchant_audit_logs");
migrationBuilder.AlterColumn<string>(
name: "Title",
table: "merchant_audit_logs",
type: "character varying(128)",
maxLength: 128,
nullable: false,
comment: "标题。",
oldClrType: typeof(string),
oldType: "character varying(200)",
oldMaxLength: 200,
oldComment: "标题。");
migrationBuilder.AlterColumn<string>(
name: "OperatorName",
table: "merchant_audit_logs",
type: "character varying(64)",
maxLength: 64,
nullable: true,
comment: "操作人名称。",
oldClrType: typeof(string),
oldType: "character varying(100)",
oldMaxLength: 100,
oldNullable: true,
oldComment: "操作人名称。");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -124,6 +124,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
.HasColumnType("character varying(1024)")
.HasComment("详情描述。");
b.Property<string>("IpAddress")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasComment("操作 IP。");
b.Property<long>("MerchantId")
.HasColumnType("bigint")
.HasComment("商户标识。");
@@ -133,8 +138,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
.HasComment("操作人 ID。");
b.Property<string>("OperatorName")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("操作人名称。");
b.Property<long>("TenantId")
@@ -143,8 +148,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasComment("标题。");
b.Property<DateTime?>("UpdatedAt")
@@ -157,6 +162,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
b.HasKey("Id");
b.HasIndex("MerchantId", "CreatedAt");
b.HasIndex("TenantId", "CreatedAt");
b.HasIndex("TenantId", "MerchantId");
b.ToTable("merchant_audit_logs", null, t =>
@@ -165,6 +174,93 @@ namespace TakeoutSaaS.Infrastructure.Migrations.LogsDb
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantChangeLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasComment("实体唯一标识。");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ChangeReason")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasComment("变更原因。");
b.Property<string>("ChangeType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasComment("变更类型。");
b.Property<long?>("ChangedBy")
.HasColumnType("bigint")
.HasComment("变更人 ID。");
b.Property<string>("ChangedByName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("变更人名称。");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("创建时间UTC。");
b.Property<long?>("CreatedBy")
.HasColumnType("bigint")
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasComment("软删除时间UTC未删除时为 null。");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasComment("删除人用户标识(软删除),未删除时为 null。");
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("变更字段名。");
b.Property<long>("MerchantId")
.HasColumnType("bigint")
.HasComment("商户标识。");
b.Property<string>("NewValue")
.HasColumnType("text")
.HasComment("变更后值。");
b.Property<string>("OldValue")
.HasColumnType("text")
.HasComment("变更前值。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
.HasComment("所属租户 ID。");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasComment("最近一次更新时间UTC从未更新时为 null。");
b.Property<long?>("UpdatedBy")
.HasColumnType("bigint")
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
b.HasKey("Id");
b.HasIndex("MerchantId", "CreatedAt");
b.HasIndex("TenantId", "CreatedAt");
b.ToTable("merchant_change_logs", null, t =>
{
t.HasComment("商户变更日志。");
});
});
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.OperationLog", b =>
{
b.Property<long>("Id")

View File

@@ -17,7 +17,7 @@ namespace TakeoutSaaS.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -2373,6 +2373,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(256)")
.HasComment("详细地址。");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("timestamp with time zone")
.HasComment("审核通过时间。");
b.Property<long?>("ApprovedBy")
.HasColumnType("bigint")
.HasComment("审核通过人。");
b.Property<string>("BrandAlias")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
@@ -2402,6 +2410,23 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(64)")
.HasComment("所在城市。");
b.Property<DateTime?>("ClaimExpiresAt")
.HasColumnType("timestamp with time zone")
.HasComment("领取过期时间。");
b.Property<DateTime?>("ClaimedAt")
.HasColumnType("timestamp with time zone")
.HasComment("领取时间。");
b.Property<long?>("ClaimedBy")
.HasColumnType("bigint")
.HasComment("当前领取人。");
b.Property<string>("ClaimedByName")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("当前领取人姓名。");
b.Property<string>("ContactEmail")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
@@ -2434,6 +2459,21 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(64)")
.HasComment("所在区县。");
b.Property<DateTime?>("FrozenAt")
.HasColumnType("timestamp with time zone")
.HasComment("冻结时间。");
b.Property<string>("FrozenReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasComment("冻结原因。");
b.Property<bool>("IsFrozen")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasComment("是否冻结业务。");
b.Property<DateTime?>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasComment("入驻时间。");
@@ -2442,6 +2482,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("timestamp with time zone")
.HasComment("最近一次审核时间。");
b.Property<long?>("LastReviewedBy")
.HasColumnType("bigint")
.HasComment("最近一次审核人。");
b.Property<double?>("Latitude")
.HasColumnType("double precision")
.HasComment("纬度信息。");
@@ -2459,6 +2503,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("double precision")
.HasComment("经度信息。");
b.Property<int?>("OperatingMode")
.HasColumnType("integer")
.HasComment("经营模式(同一主体/不同主体)。");
b.Property<string>("Province")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
@@ -2469,6 +2517,13 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(512)")
.HasComment("审核备注或驳回原因。");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("bytea")
.HasComment("并发控制版本。");
b.Property<string>("ServicePhone")
.HasColumnType("text")
.HasComment("客服电话。");
@@ -2499,8 +2554,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ClaimedBy");
b.HasIndex("TenantId");
b.HasIndex("TenantId", "Status");
b.ToTable("merchants", null, t =>
{
t.HasComment("商户主体信息,承载入驻和资质审核结果。");
@@ -4784,6 +4843,16 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(256)")
.HasComment("门店营业时段描述(备用字符串)。");
b.Property<string>("BusinessLicenseImageUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasComment("门店营业执照图片地址(主体不一致模式使用)。");
b.Property<string>("BusinessLicenseNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasComment("门店营业执照号(主体不一致模式使用)。");
b.Property<string>("City")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
@@ -4837,6 +4906,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("double precision")
.HasComment("纬度。");
b.Property<string>("LegalRepresentative")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasComment("门店法人(主体不一致模式使用)。");
b.Property<double?>("Longitude")
.HasColumnType("double precision")
.HasComment("高德/腾讯地图经度。");
@@ -4866,6 +4940,11 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(64)")
.HasComment("所在省份。");
b.Property<string>("RegisteredAddress")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasComment("门店注册地址(主体不一致模式使用)。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("门店当前运营状态。");
@@ -4908,6 +4987,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("MerchantId", "BusinessLicenseNumber")
.IsUnique()
.HasFilter("\"BusinessLicenseNumber\" IS NOT NULL AND \"Status\" <> 3");
b.HasIndex("TenantId", "Code")
.IsUnique();
@@ -5704,6 +5787,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("character varying(128)")
.HasComment("租户全称或品牌名称。");
b.Property<int?>("OperatingMode")
.HasColumnType("integer")
.HasComment("经营模式(同一主体/不同主体)。");
b.Property<long?>("PrimaryOwnerUserId")
.HasColumnType("bigint")
.HasComment("系统内对应的租户所有者账号 ID。");
@@ -5806,6 +5893,18 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("timestamp with time zone")
.HasComment("失效时间UTC为空表示长期有效。");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasComment("是否启用(已弃用,迁移期保留)。");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasComment("展示优先级,数值越大越靠前。");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasComment("实际发布时间UTC。");
b.Property<int>("PublisherScope")
.HasColumnType("integer")
.HasComment("发布者范围。");
@@ -5814,32 +5913,10 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bigint")
.HasComment("发布者用户 ID平台或租户后台账号。");
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("公告状态。");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("timestamp with time zone")
.HasComment("实际发布时间UTC。");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasComment("撤销时间UTC。");
b.Property<DateTime?>("ScheduledPublishAt")
.HasColumnType("timestamp with time zone")
.HasComment("预定发布时间UTC。");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("目标受众类型。");
b.Property<string>("TargetParameters")
.HasColumnType("text")
.HasComment("目标受众参数JSON。");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
@@ -5847,13 +5924,23 @@ namespace TakeoutSaaS.Infrastructure.Migrations
.HasColumnType("bytea")
.HasComment("并发控制字段。");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasComment("是否启用(已弃用,迁移期保留)。");
b.Property<DateTime?>("ScheduledPublishAt")
.HasColumnType("timestamp with time zone")
.HasComment("预定发布时间UTC)。");
b.Property<int>("Priority")
b.Property<int>("Status")
.HasColumnType("integer")
.HasComment("展示优先级,数值越大越靠前。");
.HasComment("公告状态。");
b.Property<string>("TargetParameters")
.HasColumnType("text")
.HasComment("目标受众参数JSON。");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasComment("目标受众类型。");
b.Property<long>("TenantId")
.HasColumnType("bigint")
@@ -5875,15 +5962,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("Status", "EffectiveFrom")
.HasFilter("\"TenantId\" = 0");
b.HasIndex("TenantId", "AnnouncementType", "IsActive");
b.HasIndex("TenantId", "EffectiveFrom", "EffectiveTo");
b.HasIndex("TenantId", "Status", "EffectiveFrom");
b.HasIndex("Status", "EffectiveFrom")
.HasFilter("\"TenantId\" = 0");
b.ToTable("tenant_announcements", null, t =>
{
t.HasComment("租户公告。");