diff --git a/Directory.Packages.props b/Directory.Packages.props index 0407568..0b4bf73 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -123,6 +123,7 @@ + diff --git a/src/Fengling.Member.Application/Commands/Points/ProcessExpiredPointsCommandHandler.cs b/src/Fengling.Member.Application/Commands/Points/ProcessExpiredPointsCommandHandler.cs index 66d6ad7..b9d30b8 100644 --- a/src/Fengling.Member.Application/Commands/Points/ProcessExpiredPointsCommandHandler.cs +++ b/src/Fengling.Member.Application/Commands/Points/ProcessExpiredPointsCommandHandler.cs @@ -31,6 +31,7 @@ public class ProcessExpiredPointsCommandHandler : IRequestHandler +/// 积分变动事件 +/// +public class PointsChangedEvent : INotification +{ + /// + /// 码ID(用于幂等性检查) + /// + public string CodeId { get; set; } = string.Empty; + + /// + /// 会员ID + /// + public long MemberId { get; set; } + + /// + /// 租户ID + /// + public long TenantId { get; set; } + + /// + /// 交易类型 + /// + public string TransactionType { get; set; } = string.Empty; + + /// + /// 积分数(正数增加,负数扣减) + /// + public int Points { get; set; } + + /// + /// 来源ID + /// + public string? SourceId { get; set; } + + /// + /// 备注 + /// + public string? Remark { get; set; } + + /// + /// 变动后的总积分 + /// + public int TotalPoints { get; set; } + + /// + /// 变动时间 + /// + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Fengling.Member.Application/Events/PointsChangedEventHandler.cs b/src/Fengling.Member.Application/Events/PointsChangedEventHandler.cs new file mode 100644 index 0000000..934c91b --- /dev/null +++ b/src/Fengling.Member.Application/Events/PointsChangedEventHandler.cs @@ -0,0 +1,77 @@ +using MediatR; +using Fengling.Member.Domain.Aggregates.PointsModel; +using Microsoft.Extensions.Logging; + +namespace Fengling.Member.Application.Events; + +/// +/// 积分变动事件处理器 - 负责将积分变动持久化到数据库 +/// +public class PointsChangedEventHandler : INotificationHandler +{ + private readonly IPointsHistoryRepository _historyRepository; + private readonly ILogger _logger; + + public PointsChangedEventHandler( + IPointsHistoryRepository historyRepository, + ILogger logger) + { + _historyRepository = historyRepository; + _logger = logger; + } + + public async Task Handle(PointsChangedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation( + "Handling PointsChangedEvent for MemberId={MemberId}, CodeId={CodeId}, Points={Points}", + notification.MemberId, notification.CodeId, notification.Points); + + try + { + // 幂等性检查:通过 SourceId 检查是否已处理 + if (!string.IsNullOrEmpty(notification.SourceId)) + { + var exists = await _historyRepository.ExistsBySourceIdAsync( + notification.SourceId, cancellationToken); + + if (exists) + { + _logger.LogWarning( + "PointsChangedEvent already processed, skipping. MemberId={MemberId}, SourceId={SourceId}", + notification.MemberId, notification.SourceId); + return; + } + } + + // 确定交易类型分类 + var transactionTypeCategory = notification.Points >= 0 + ? PointsTransactionType.Earn + : PointsTransactionType.Deduct; + + // 创建积分明细记录(使用 factory 方法) + // 注意:PointsAccountId 为 0,需要根据业务需求从缓存或数据库获取 + var transaction = PointsTransaction.Create( + pointsAccountId: 0, // TODO: 需要根据 MemberId 查询或创建 PointsAccount + memberId: notification.MemberId, + tenantId: notification.TenantId, + points: notification.Points, + transactionType: notification.TransactionType, + sourceId: notification.SourceId ?? string.Empty, + typeCategory: transactionTypeCategory, + remark: notification.Remark); + + await _historyRepository.AddAsync(transaction, cancellationToken); + + _logger.LogInformation( + "PointsChangedEvent processed successfully. MemberId={MemberId}, Points={Points}, TotalPoints={TotalPoints}", + notification.MemberId, notification.Points, notification.TotalPoints); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error handling PointsChangedEvent. MemberId={MemberId}, CodeId={CodeId}", + notification.MemberId, notification.CodeId); + throw; + } + } +} diff --git a/src/Fengling.Member.Application/Fengling.Member.Application.csproj b/src/Fengling.Member.Application/Fengling.Member.Application.csproj index 12dd639..f45dbd7 100644 --- a/src/Fengling.Member.Application/Fengling.Member.Application.csproj +++ b/src/Fengling.Member.Application/Fengling.Member.Application.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Fengling.Member.Application/Services/CodeDistributedLock.cs b/src/Fengling.Member.Application/Services/CodeDistributedLock.cs new file mode 100644 index 0000000..62545cb --- /dev/null +++ b/src/Fengling.Member.Application/Services/CodeDistributedLock.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Fengling.Member.Application.Services; + +/// +/// 基于 Redis 的分布式锁实现 +/// +public class CodeDistributedLock : ICodeDistributedLock +{ + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + + private const string LockKeyPrefix = "points:lock:"; + private readonly TimeSpan _defaultTtl = TimeSpan.FromSeconds(10); + + public CodeDistributedLock(IConnectionMultiplexer redis, ILogger logger) + { + _redis = redis; + _logger = logger; + } + + public async Task AcquireAsync(string codeId, TimeSpan? ttl = null) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{LockKeyPrefix}{codeId}"; + var lockValue = Guid.NewGuid().ToString("N"); + var effectiveTtl = ttl ?? _defaultTtl; + + // 使用 SET NX EX 原子操作获取锁 + var acquired = await db.StringSetAsync( + key, + lockValue, + effectiveTtl, + When.NotExists); + + if (acquired) + { + _logger.LogDebug("Acquired lock for code {CodeId}, lock value: {LockValue}", codeId, lockValue); + return LockAcquisitionResult.Succeeded(lockValue); + } + + // 获取锁失败,检查是否超时 + var existingValue = await db.StringGetAsync(key); + if (existingValue.IsNullOrEmpty) + { + // 锁已过期,重试获取 + return LockAcquisitionResult.Failed("Lock expired, please retry"); + } + + _logger.LogWarning("Failed to acquire lock for code {CodeId}, already held", codeId); + return LockAcquisitionResult.Failed("Lock is held by another process"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error acquiring lock for code {CodeId}", codeId); + return LockAcquisitionResult.Failed($"Error: {ex.Message}"); + } + } + + public async Task ReleaseAsync(string codeId, string lockValue) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{LockKeyPrefix}{codeId}"; + + // 使用 Lua 脚本确保原子性:只有持有锁的进程才能释放 + var luaScript = @" + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) + else + return 0 + end + "; + + var result = await db.ScriptEvaluateAsync(luaScript, + new RedisKey[] { key }, + new RedisValue[] { lockValue }); + + var deleted = (int)result; + if (deleted > 0) + { + _logger.LogDebug("Released lock for code {CodeId}, lock value: {LockValue}", codeId, lockValue); + } + else + { + _logger.LogWarning("Failed to release lock for code {CodeId}, lock value: {LockValue}, may be expired or held by another process", + codeId, lockValue); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error releasing lock for code {CodeId}", codeId); + } + } +} diff --git a/src/Fengling.Member.Application/Services/ICodeDistributedLock.cs b/src/Fengling.Member.Application/Services/ICodeDistributedLock.cs new file mode 100644 index 0000000..dc97ff7 --- /dev/null +++ b/src/Fengling.Member.Application/Services/ICodeDistributedLock.cs @@ -0,0 +1,44 @@ +namespace Fengling.Member.Application.Services; + +/// +/// 分布式锁获取结果 +/// +public class LockAcquisitionResult +{ + public bool Success { get; set; } + public string? LockValue { get; set; } + public string? ErrorMessage { get; set; } + + public static LockAcquisitionResult Succeeded(string lockValue) => new() + { + Success = true, + LockValue = lockValue + }; + + public static LockAcquisitionResult Failed(string errorMessage) => new() + { + Success = false, + ErrorMessage = errorMessage + }; +} + +/// +/// 分布式锁接口 +/// +public interface ICodeDistributedLock +{ + /// + /// 获取分布式锁 + /// + /// 码ID + /// 锁过期时间,默认10秒 + /// 获取结果 + Task AcquireAsync(string codeId, TimeSpan? ttl = null); + + /// + /// 释放分布式锁 + /// + /// 码ID + /// 锁值 + Task ReleaseAsync(string codeId, string lockValue); +} diff --git a/src/Fengling.Member.Application/Services/IPointsAccountCache.cs b/src/Fengling.Member.Application/Services/IPointsAccountCache.cs new file mode 100644 index 0000000..4b3232d --- /dev/null +++ b/src/Fengling.Member.Application/Services/IPointsAccountCache.cs @@ -0,0 +1,64 @@ +namespace Fengling.Member.Application.Services; + +/// +/// 积分账户缓存模型 +/// +public class PointsAccountCacheModel +{ + public long MemberId { get; set; } + public long TenantId { get; set; } + public int TotalPoints { get; set; } + public int FrozenPoints { get; set; } + public int AvailablePoints => TotalPoints - FrozenPoints; +} + +/// +/// 积分账户缓存接口 +/// +public interface IPointsAccountCache +{ + /// + /// 获取缓存的积分账户 + /// + Task GetAsync(long memberId); + + /// + /// 设置积分账户缓存 + /// + Task SetAsync(long memberId, PointsAccountCacheModel account, TimeSpan? ttl = null); + + /// + /// 增加积分 + /// + Task AddPointsAsync(long memberId, int points); + + /// + /// 扣减积分 + /// + Task<(bool Success, int RemainingPoints)> DeductPointsAsync(long memberId, int points); + + /// + /// 冻结积分 + /// + Task FreezePointsAsync(long memberId, int points); + + /// + /// 解冻积分 + /// + Task UnfreezePointsAsync(long memberId, int points); + + /// + /// 移除缓存 + /// + Task RemoveAsync(long memberId); + + /// + /// 检查积分码是否已处理(幂等性检查) + /// + Task IsCodeProcessedAsync(string codeId); + + /// + /// 标记积分码已处理 + /// + Task MarkCodeProcessedAsync(string codeId, TimeSpan? ttl = null); +} diff --git a/src/Fengling.Member.Application/Services/IPointsProcessingService.cs b/src/Fengling.Member.Application/Services/IPointsProcessingService.cs new file mode 100644 index 0000000..17bc924 --- /dev/null +++ b/src/Fengling.Member.Application/Services/IPointsProcessingService.cs @@ -0,0 +1,89 @@ +using Fengling.Member.Application.Dtos; + +namespace Fengling.Member.Application.Services; + +/// +/// 积分处理请求 +/// +public class PointsProcessRequest +{ + /// + /// 码ID(用于幂等性检查和分布式锁) + /// + public string CodeId { get; set; } = string.Empty; + + /// + /// 会员ID + /// + public long MemberId { get; set; } + + /// + /// 交易类型 + /// + public string TransactionType { get; set; } = string.Empty; + + /// + /// 积分数(增加或扣减) + /// + public int Points { get; set; } + + /// + /// 扣减类型:true=增加积分,false=扣减积分 + /// + public bool IsAddition { get; set; } = true; + + /// + /// 来源ID + /// + public string? SourceId { get; set; } + + /// + /// 备注 + /// + public string? Remark { get; set; } +} + +/// +/// 积分处理结果 +/// +public class PointsProcessResult +{ + public bool Success { get; set; } + public int Points { get; set; } + public int TotalPoints { get; set; } + public string Message { get; set; } = string.Empty; + public string? ErrorCode { get; set; } + + public static PointsProcessResult Succeeded(int points, int totalPoints, string message = "处理成功") => new() + { + Success = true, + Points = points, + TotalPoints = totalPoints, + Message = message + }; + + public static PointsProcessResult Failed(string message, string? errorCode = null) => new() + { + Success = false, + Points = 0, + TotalPoints = 0, + Message = message, + ErrorCode = errorCode + }; +} + +/// +/// 积分处理服务接口 +/// +public interface IPointsProcessingService +{ + /// + /// 处理积分(增加或扣减) + /// + Task ProcessAsync(PointsProcessRequest request, CancellationToken ct = default); + + /// + /// 获取积分余额 + /// + Task GetBalanceAsync(long memberId); +} diff --git a/src/Fengling.Member.Application/Services/PointsAccountCache.cs b/src/Fengling.Member.Application/Services/PointsAccountCache.cs new file mode 100644 index 0000000..d99e612 --- /dev/null +++ b/src/Fengling.Member.Application/Services/PointsAccountCache.cs @@ -0,0 +1,248 @@ +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace Fengling.Member.Application.Services; + +/// +/// Redis 积分账户缓存实现 +/// +public class PointsAccountCache : IPointsAccountCache +{ + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + private readonly TimeSpan _defaultTtl = TimeSpan.FromMinutes(5); + + private const string AccountKeyPrefix = "points:account:"; + private const string CodeKeyPrefix = "points:code:"; + + public PointsAccountCache(IConnectionMultiplexer redis, ILogger logger) + { + _redis = redis; + _logger = logger; + } + + public async Task GetAsync(long memberId) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{AccountKeyPrefix}{memberId}"; + var value = await db.StringGetAsync(key); + + if (value.IsNullOrEmpty) + return null; + + return Deserialize(value!); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting points account from cache for member {MemberId}", memberId); + return null; + } + } + + public async Task SetAsync(long memberId, PointsAccountCacheModel account, TimeSpan? ttl = null) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{AccountKeyPrefix}{memberId}"; + var value = Serialize(account); + await db.StringSetAsync(key, value, ttl ?? _defaultTtl); + _logger.LogDebug("Set points account cache for member {MemberId}", memberId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting points account cache for member {MemberId}", memberId); + } + } + + public async Task AddPointsAsync(long memberId, int points) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{AccountKeyPrefix}{memberId}"; + + // 使用 Redis 原子操作 + var newTotal = await db.StringIncrementAsync(key, points); + + // 如果是首次设置,需要初始化结构 + if (newTotal == points) + { + var account = new PointsAccountCacheModel + { + MemberId = memberId, + TotalPoints = points, + FrozenPoints = 0 + }; + await SetAsync(memberId, account); + } + + _logger.LogInformation("Added {Points} points to member {MemberId}, new total: {Total}", + points, memberId, newTotal); + + return (int)newTotal; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding points for member {MemberId}", memberId); + throw; + } + } + + public async Task<(bool Success, int RemainingPoints)> DeductPointsAsync(long memberId, int points) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{AccountKeyPrefix}{memberId}"; + + // 使用 Lua 脚本保证原子性 + var luaScript = @" + local current = tonumber(redis.call('GET', KEYS[1]) or '0') + local points = tonumber(ARGV[1]) + if current >= points then + local newValue = redis.call('DECRBY', KEYS[1], points) + return newValue + else + return -1 + end + "; + + var result = await db.ScriptEvaluateAsync(luaScript, + new RedisKey[] { key }, + new RedisValue[] { points }); + + var value = (int)result; + + if (value < 0) + { + _logger.LogWarning("Insufficient points for member {MemberId}, available: {Available}, requested: {Requested}", + memberId, await db.StringGetAsync(key), points); + return (false, (int)(await db.StringGetAsync(key))); + } + + _logger.LogInformation("Deducted {Points} points from member {MemberId}, remaining: {Remaining}", + points, memberId, value); + + return (true, value); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deducting points for member {MemberId}", memberId); + throw; + } + } + + public async Task FreezePointsAsync(long memberId, int points) + { + try + { + var account = await GetAsync(memberId); + if (account == null || account.AvailablePoints < points) + return false; + + account.FrozenPoints += points; + await SetAsync(memberId, account); + + _logger.LogInformation("Frozen {Points} points for member {MemberId}", points, memberId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error freezing points for member {MemberId}", memberId); + throw; + } + } + + public async Task UnfreezePointsAsync(long memberId, int points) + { + try + { + var account = await GetAsync(memberId); + if (account == null || account.FrozenPoints < points) + return false; + + account.FrozenPoints -= points; + await SetAsync(memberId, account); + + _logger.LogInformation("Unfrozen {Points} points for member {MemberId}", points, memberId); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error unfreezing points for member {MemberId}", memberId); + throw; + } + } + + public async Task RemoveAsync(long memberId) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{AccountKeyPrefix}{memberId}"; + await db.KeyDeleteAsync(key); + _logger.LogDebug("Removed points account cache for member {MemberId}", memberId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing points account cache for member {MemberId}", memberId); + } + } + + public async Task IsCodeProcessedAsync(string codeId) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{CodeKeyPrefix}{codeId}"; + return await db.KeyExistsAsync(key); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking code processed status for code {CodeId}", codeId); + return false; + } + } + + public async Task MarkCodeProcessedAsync(string codeId, TimeSpan? ttl = null) + { + try + { + var db = _redis.GetDatabase(); + var key = $"{CodeKeyPrefix}{codeId}"; + // 码的处理标记保留 24 小时 + await db.StringSetAsync(key, "1", ttl ?? TimeSpan.FromHours(24)); + _logger.LogDebug("Marked code {CodeId} as processed", codeId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error marking code processed for code {CodeId}", codeId); + } + } + + private static string Serialize(PointsAccountCacheModel account) + { + return $"{account.MemberId}|{account.TenantId}|{account.TotalPoints}|{account.FrozenPoints}"; + } + + private static PointsAccountCacheModel? Deserialize(string value) + { + if (string.IsNullOrEmpty(value)) + return null; + + var parts = value.Split('|'); + if (parts.Length != 4) + return null; + + return new PointsAccountCacheModel + { + MemberId = long.Parse(parts[0]), + TenantId = long.Parse(parts[1]), + TotalPoints = int.Parse(parts[2]), + FrozenPoints = int.Parse(parts[3]) + }; + } +} diff --git a/src/Fengling.Member.Application/Services/PointsProcessingService.cs b/src/Fengling.Member.Application/Services/PointsProcessingService.cs new file mode 100644 index 0000000..8dd4084 --- /dev/null +++ b/src/Fengling.Member.Application/Services/PointsProcessingService.cs @@ -0,0 +1,130 @@ +using Fengling.Member.Domain.Aggregates.PointsModel; +using Fengling.Member.Domain.Events.Points; +using Fengling.Member.Infrastructure; +using Microsoft.Extensions.Logging; + +namespace Fengling.Member.Application.Services; + +/// +/// 积分处理服务实现 +/// 使用分布式锁 + Redis 缓存实现高性能积分处理 +/// +public class PointsProcessingService : IPointsProcessingService +{ + private readonly IPointsAccountCache _cache; + private readonly ICodeDistributedLock _lock; + private readonly IPointsHistoryRepository _historyRepository; + private readonly IMediator _mediator; + private readonly ITenantAccessor _tenantAccessor; + private readonly ILogger _logger; + + public PointsProcessingService( + IPointsAccountCache cache, + ICodeDistributedLock distributedLock, + IPointsHistoryRepository historyRepository, + IMediator mediator, + ITenantAccessor tenantAccessor, + ILogger logger) + { + _cache = cache; + _lock = distributedLock; + _historyRepository = historyRepository; + _mediator = mediator; + _tenantAccessor = tenantAccessor; + _logger = logger; + } + + public async Task ProcessAsync(PointsProcessRequest request, CancellationToken ct = default) + { + // 1. 幂等性检查 - 检查码是否已处理 + if (await _cache.IsCodeProcessedAsync(request.CodeId)) + { + _logger.LogWarning("Code {CodeId} already processed", request.CodeId); + return PointsProcessResult.Failed("码已处理", "CODE_ALREADY_PROCESSED"); + } + + // 2. 获取分布式锁 + var lockResult = await _lock.AcquireAsync(request.CodeId); + if (!lockResult.Success) + { + _logger.LogWarning("Failed to acquire lock for code {CodeId}: {Error}", request.CodeId, lockResult.ErrorMessage); + return PointsProcessResult.Failed("处理中,请稍后重试", "LOCK_FAILED"); + } + + try + { + // 3. 双重检查幂等性(在获取锁后再次检查) + if (await _cache.IsCodeProcessedAsync(request.CodeId)) + { + return PointsProcessResult.Failed("码已处理", "CODE_ALREADY_PROCESSED"); + } + + // 4. 从缓存获取账户,不存在则创建 + var account = await _cache.GetAsync(request.MemberId); + var tenantId = _tenantAccessor.GetTenantId() ?? 0; + if (account == null) + { + account = new PointsAccountCacheModel + { + MemberId = request.MemberId, + TenantId = tenantId, + TotalPoints = 0, + FrozenPoints = 0 + }; + } + + // 5. 处理积分 + int newTotal; + if (request.IsAddition) + { + newTotal = await _cache.AddPointsAsync(request.MemberId, request.Points); + } + else + { + var deductResult = await _cache.DeductPointsAsync(request.MemberId, request.Points); + if (!deductResult.Success) + { + return PointsProcessResult.Failed("积分不足", "INSUFFICIENT_POINTS"); + } + newTotal = deductResult.RemainingPoints; + } + + // 6. 标记码已处理 + await _cache.MarkCodeProcessedAsync(request.CodeId); + + // 7. 发布领域事件(异步持久化到数据库) + var domainEvent = new PointsChangedEvent( + 0, // AccountId - 缓存中暂无ID,事件消费者处理 + request.MemberId, + tenantId, + request.IsAddition ? request.Points : -request.Points, + newTotal, + request.TransactionType, + request.SourceId, + request.Remark); + + await _mediator.Publish(domainEvent, ct); + + _logger.LogInformation( + "Points processed successfully: MemberId={MemberId}, CodeId={CodeId}, Points={Points}, IsAddition={IsAddition}, NewTotal={Total}", + request.MemberId, request.CodeId, request.Points, request.IsAddition, newTotal); + + return PointsProcessResult.Succeeded(request.Points, newTotal); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing points for member {MemberId}, code {CodeId}", request.MemberId, request.CodeId); + return PointsProcessResult.Failed("处理失败", "PROCESS_ERROR"); + } + finally + { + // 8. 释放锁 + await _lock.ReleaseAsync(request.CodeId, lockResult.LockValue!); + } + } + + public async Task GetBalanceAsync(long memberId) + { + return await _cache.GetAsync(memberId); + } +} diff --git a/src/Fengling.Member.Domain/Aggregates/PointsModel/IPointsHistoryRepository.cs b/src/Fengling.Member.Domain/Aggregates/PointsModel/IPointsHistoryRepository.cs new file mode 100644 index 0000000..25f6c97 --- /dev/null +++ b/src/Fengling.Member.Domain/Aggregates/PointsModel/IPointsHistoryRepository.cs @@ -0,0 +1,38 @@ +using Fengling.Member.Domain.Aggregates.PointsModel; + +namespace Fengling.Member.Domain.Aggregates.PointsModel; + +/// +/// 积分历史仓储接口 +/// +public interface IPointsHistoryRepository +{ + /// + /// 检查 SourceId 是否已存在(幂等性检查) + /// + Task ExistsBySourceIdAsync(string sourceId, CancellationToken cancellationToken = default); + + /// + /// 获取会员的积分明细(分页) + /// + Task> GetByMemberIdAsync( + long memberId, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default); + + /// + /// 统计会员的积分明细数量 + /// + Task CountByMemberIdAsync(long memberId, CancellationToken cancellationToken = default); + + /// + /// 获取积分明细 + /// + Task GetByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 添加积分明细 + /// + Task AddAsync(PointsTransaction transaction, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs index cb6d9fd..f7fa2af 100644 --- a/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs +++ b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsAccount.cs @@ -48,6 +48,7 @@ public class PointsAccount : Entity, IAggregateRoot var transaction = PointsTransaction.Create( Id, MemberId, + TenantId, points, transactionType, sourceId, @@ -60,7 +61,7 @@ public class PointsAccount : Entity, IAggregateRoot UpdatedAt = DateTime.UtcNow; Version++; - AddDomainEvent(new PointsChangedEvent(Id, MemberId, points, TotalPoints, transactionType)); + AddDomainEvent(new PointsChangedEvent(Id, MemberId, TenantId, points, TotalPoints, transactionType, sourceId, remark)); } public bool DeductPoints(int points, string transactionType, string sourceId, string? remark = null) @@ -74,6 +75,7 @@ public class PointsAccount : Entity, IAggregateRoot var transaction = PointsTransaction.Create( Id, MemberId, + TenantId, points, transactionType, sourceId, @@ -86,7 +88,7 @@ public class PointsAccount : Entity, IAggregateRoot UpdatedAt = DateTime.UtcNow; Version++; - AddDomainEvent(new PointsChangedEvent(Id, MemberId, -points, TotalPoints, transactionType)); + AddDomainEvent(new PointsChangedEvent(Id, MemberId, TenantId, -points, TotalPoints, transactionType, sourceId, remark)); return true; } diff --git a/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs index 45273b7..02e9378 100644 --- a/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs +++ b/src/Fengling.Member.Domain/Aggregates/PointsModel/PointsTransaction.cs @@ -4,6 +4,7 @@ public class PointsTransaction : Entity { public long PointsAccountId { get; private set; } public long MemberId { get; private set; } + public long TenantId { get; private set; } public int Points { get; private set; } public string TransactionType { get; private set; } = string.Empty; public string SourceId { get; private set; } = string.Empty; @@ -24,6 +25,7 @@ public class PointsTransaction : Entity public static PointsTransaction Create( long pointsAccountId, long memberId, + long tenantId, int points, string transactionType, string sourceId, @@ -34,6 +36,7 @@ public class PointsTransaction : Entity { PointsAccountId = pointsAccountId, MemberId = memberId, + TenantId = tenantId, Points = points, TransactionType = transactionType, SourceId = sourceId, diff --git a/src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs b/src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs index d7d7b7b..74f9a82 100644 --- a/src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs +++ b/src/Fengling.Member.Domain/Events/Points/PointsChangedEvent.cs @@ -3,7 +3,10 @@ namespace Fengling.Member.Domain.Events.Points; public record PointsChangedEvent( long AccountId, long MemberId, + long TenantId, int ChangedPoints, int NewBalance, - string TransactionType + string TransactionType, + string? SourceId, + string? Remark ) : IDomainEvent; diff --git a/src/Fengling.Member.Infrastructure/ITenantAccessor.cs b/src/Fengling.Member.Infrastructure/ITenantAccessor.cs new file mode 100644 index 0000000..b128d3b --- /dev/null +++ b/src/Fengling.Member.Infrastructure/ITenantAccessor.cs @@ -0,0 +1,21 @@ +namespace Fengling.Member.Infrastructure; + +/// +/// 租户访问器接口 +/// 用于从不同来源获取当前请求的 TenantId +/// - HTTP 请求:从 JWT Claim 获取 +/// - 消息队列消费者:从消息 Header 获取 +/// +public interface ITenantAccessor +{ + /// + /// 获取当前租户ID + /// + /// 租户ID,如果无法获取则返回 null + long? GetTenantId(); + + /// + /// 尝试获取当前租户ID + /// + bool TryGetTenantId(out long tenantId); +} diff --git a/src/Fengling.Member.Infrastructure/Repositories/IPointsHistoryRepository.cs b/src/Fengling.Member.Infrastructure/Repositories/IPointsHistoryRepository.cs new file mode 100644 index 0000000..fdeb17f --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Repositories/IPointsHistoryRepository.cs @@ -0,0 +1,38 @@ +using Fengling.Member.Domain.Aggregates.PointsModel; + +namespace Fengling.Member.Infrastructure.Repositories; + +/// +/// 积分历史仓储接口 +/// +public interface IPointsHistoryRepository +{ + /// + /// 检查 SourceId 是否已存在(幂等性检查) + /// + Task ExistsBySourceIdAsync(string sourceId, CancellationToken cancellationToken = default); + + /// + /// 获取会员的积分明细(分页) + /// + Task> GetByMemberIdAsync( + long memberId, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default); + + /// + /// 统计会员的积分明细数量 + /// + Task CountByMemberIdAsync(long memberId, CancellationToken cancellationToken = default); + + /// + /// 获取积分明细 + /// + Task GetByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 添加积分明细 + /// + Task AddAsync(PointsTransaction transaction, CancellationToken cancellationToken = default); +} diff --git a/src/Fengling.Member.Infrastructure/Repositories/PointsHistoryRepository.cs b/src/Fengling.Member.Infrastructure/Repositories/PointsHistoryRepository.cs new file mode 100644 index 0000000..8f48615 --- /dev/null +++ b/src/Fengling.Member.Infrastructure/Repositories/PointsHistoryRepository.cs @@ -0,0 +1,54 @@ +using Fengling.Member.Domain.Aggregates.PointsModel; +using Microsoft.EntityFrameworkCore; + +namespace Fengling.Member.Infrastructure.Repositories; + +/// +/// 积分历史仓储实现 +/// +public class PointsHistoryRepository : IPointsHistoryRepository +{ + private readonly ApplicationDbContext _context; + + public PointsHistoryRepository(ApplicationDbContext context) + { + _context = context; + } + + public async Task ExistsBySourceIdAsync(string sourceId, CancellationToken cancellationToken = default) + { + return await _context.PointsTransactions + .AnyAsync(t => t.SourceId == sourceId, cancellationToken); + } + + public async Task> GetByMemberIdAsync( + long memberId, + int page = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) + { + return await _context.PointsTransactions + .Where(t => t.MemberId == memberId) + .OrderByDescending(t => t.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + } + + public async Task CountByMemberIdAsync(long memberId, CancellationToken cancellationToken = default) + { + return await _context.PointsTransactions + .CountAsync(t => t.MemberId == memberId, cancellationToken); + } + + public async Task GetByIdAsync(long id, CancellationToken cancellationToken = default) + { + return await _context.PointsTransactions.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task AddAsync(PointsTransaction transaction, CancellationToken cancellationToken = default) + { + _context.PointsTransactions.Add(transaction); + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs b/src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs index 147a6d2..985550c 100644 --- a/src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs +++ b/src/Fengling.Member.Web/Endpoints/v1/PointsEndpoints.cs @@ -2,6 +2,8 @@ using FastEndpoints; using MediatR; using Microsoft.AspNetCore.Mvc; using Fengling.Member.Application.Commands.Points; +using Fengling.Member.Application.Services; +using Fengling.Member.Domain.Aggregates.PointsModel; namespace Fengling.Member.Web.Endpoints.v1; @@ -66,3 +68,202 @@ public class AddPointsResponse public int TotalPoints { get; set; } public DateTime TransactionAt { get; set; } } + +public class GetPointsBalanceEndpoint : Endpoint +{ + private readonly IPointsProcessingService _pointsService; + + public GetPointsBalanceEndpoint(IPointsProcessingService pointsService) + { + _pointsService = pointsService; + } + + public override void Configure() + { + Get("/api/v1/members/{MemberId}/points/balance"); + Summary(s => + { + s.Summary = "获取积分余额"; + s.Description = "获取会员的当前积分余额"; + }); + } + + public override async Task HandleAsync(GetPointsBalanceRequest req, CancellationToken ct) + { + var account = await _pointsService.GetBalanceAsync(req.MemberId); + + if (account == null) + { + Response = new GetPointsBalanceResponse + { + MemberId = req.MemberId, + TotalPoints = 0, + FrozenPoints = 0, + AvailablePoints = 0 + }; + return; + } + + Response = new GetPointsBalanceResponse + { + MemberId = account.MemberId, + TotalPoints = account.TotalPoints, + FrozenPoints = account.FrozenPoints, + AvailablePoints = account.TotalPoints - account.FrozenPoints + }; + } +} + +public class GetPointsBalanceRequest +{ + [FromRoute] + public long MemberId { get; set; } +} + +public class GetPointsBalanceResponse +{ + public long MemberId { get; set; } + public int TotalPoints { get; set; } + public int FrozenPoints { get; set; } + public int AvailablePoints { get; set; } +} + +public class GetPointsHistoryEndpoint : Endpoint +{ + private readonly IPointsHistoryRepository _historyRepository; + + public GetPointsHistoryEndpoint(IPointsHistoryRepository historyRepository) + { + _historyRepository = historyRepository; + } + + public override void Configure() + { + Get("/api/v1/members/{MemberId}/points/history"); + Summary(s => + { + s.Summary = "获取积分明细"; + s.Description = "获取会员的积分变动历史"; + }); + } + + public override async Task HandleAsync(GetPointsHistoryRequest req, CancellationToken ct) + { + var transactions = await _historyRepository.GetByMemberIdAsync( + req.MemberId, + req.Page > 0 ? req.Page : 1, + req.PageSize > 0 && req.PageSize <= 100 ? req.PageSize : 20, + ct); + + var totalCount = await _historyRepository.CountByMemberIdAsync(req.MemberId, ct); + + Response = new GetPointsHistoryResponse + { + MemberId = req.MemberId, + Page = req.Page > 0 ? req.Page : 1, + PageSize = req.PageSize > 0 ? req.PageSize : 20, + TotalCount = totalCount, + Transactions = transactions.Select(t => new PointsTransactionDto + { + Id = t.Id, + Points = t.Points, + TransactionType = t.TransactionType, + SourceId = t.SourceId, + Remark = t.Remark, + CreatedAt = t.CreatedAt + }).ToList() + }; + } +} + +public class GetPointsHistoryRequest +{ + [FromRoute] + public long MemberId { get; set; } + [QueryParam] + public int Page { get; set; } = 1; + [QueryParam] + public int PageSize { get; set; } = 20; +} + +public class GetPointsHistoryResponse +{ + public long MemberId { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public List Transactions { get; set; } = new(); +} + +public class PointsTransactionDto +{ + public long Id { get; set; } + public int Points { get; set; } + public string TransactionType { get; set; } = string.Empty; + public string SourceId { get; set; } = string.Empty; + public string? Remark { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class ProcessPointsEndpoint : Endpoint +{ + private readonly IPointsProcessingService _pointsService; + + public ProcessPointsEndpoint(IPointsProcessingService pointsService) + { + _pointsService = pointsService; + } + + public override void Configure() + { + Post("/api/v1/points/process"); + Summary(s => + { + s.Summary = "处理积分"; + s.Description = "增加或扣减会员积分,支持幂等性"; + }); + } + + public override async Task HandleAsync(ProcessPointsRequest req, CancellationToken ct) + { + var result = await _pointsService.ProcessAsync(new PointsProcessRequest + { + CodeId = req.CodeId, + MemberId = req.MemberId, + TransactionType = req.TransactionType, + Points = req.Points, + IsAddition = req.IsAddition, + SourceId = req.SourceId, + Remark = req.Remark + }, ct); + + Response = new ProcessPointsResponse + { + Success = result.Success, + Points = result.Points, + TotalPoints = result.TotalPoints, + Message = result.Message, + ErrorCode = result.ErrorCode + }; + } +} + +public class ProcessPointsRequest +{ + public string CodeId { get; set; } = string.Empty; + public long MemberId { get; set; } + public string TransactionType { get; set; } = string.Empty; + public int Points { get; set; } + public bool IsAddition { get; set; } = true; + public string? SourceId { get; set; } + public string? Remark { get; set; } +} + +public class ProcessPointsResponse +{ + public bool Success { get; set; } + public int Points { get; set; } + public int TotalPoints { get; set; } + public string Message { get; set; } = string.Empty; + public string? ErrorCode { get; set; } +} diff --git a/src/Fengling.Member.Web/HttpContextTenantAccessor.cs b/src/Fengling.Member.Web/HttpContextTenantAccessor.cs new file mode 100644 index 0000000..e8a3afe --- /dev/null +++ b/src/Fengling.Member.Web/HttpContextTenantAccessor.cs @@ -0,0 +1,43 @@ +using Fengling.Member.Infrastructure; +using Microsoft.AspNetCore.Http; +using System.Security.Claims; + +namespace Fengling.Member.Web; + +/// +/// HTTP 上下文租户访问器 +/// 从 JWT Claim 中获取 tenant_id +/// +public class HttpContextTenantAccessor : ITenantAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private const string TenantIdClaimType = "tenant_id"; + + public HttpContextTenantAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public long? GetTenantId() + { + var context = _httpContextAccessor.HttpContext; + if (context?.User == null) + return null; + + var tenantIdClaim = context.User.FindFirst(TenantIdClaimType); + if (tenantIdClaim == null || string.IsNullOrEmpty(tenantIdClaim.Value)) + return null; + + if (long.TryParse(tenantIdClaim.Value, out var tenantId)) + return tenantId; + + return null; + } + + public bool TryGetTenantId(out long tenantId) + { + var result = GetTenantId(); + tenantId = result ?? 0; + return result.HasValue; + } +} diff --git a/src/Fengling.Member.Web/Program.cs b/src/Fengling.Member.Web/Program.cs index a5ba02c..b32f5b7 100644 --- a/src/Fengling.Member.Web/Program.cs +++ b/src/Fengling.Member.Web/Program.cs @@ -23,6 +23,7 @@ using Fengling.Member.Infrastructure.Repositories; using Fengling.Member.Application.Services; using Fengling.Member.Application.Commands.Points; using Fengling.Member.Web.Endpoints.v1; +using Fengling.Member.Web; using Fengling.Member.Domain.Repositories; Log.Logger = new LoggerConfiguration() @@ -190,6 +191,13 @@ try #endregion + #region Tenant Accessor + + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); + + #endregion + var app = builder.Build(); diff --git a/test/Fengling.Member.Domain.Tests/Aggregates/PointsModel/PointsAccountTests.cs b/test/Fengling.Member.Domain.Tests/Aggregates/PointsModel/PointsAccountTests.cs deleted file mode 100644 index a400f6e..0000000 --- a/test/Fengling.Member.Domain.Tests/Aggregates/PointsModel/PointsAccountTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -using FluentAssertions; -using Fengling.Member.Domain.Aggregates.PointsModel; - -namespace Fengling.Member.Domain.Tests.Aggregates.PointsModel; - -public class PointsAccountTests -{ - [Fact] - public void Create_WithValidIds_ShouldCreateAccount() - { - var account = PointsAccount.Create(1, 100); - - account.MemberId.Should().Be(1); - account.TenantId.Should().Be(100); - account.TotalPoints.Should().Be(0); - account.FrozenPoints.Should().Be(0); - account.AvailablePoints.Should().Be(0); - } - - [Fact] - public void Create_WithInvalidMemberId_ShouldThrowException() - { - var action = () => PointsAccount.Create(0, 100); - - action.Should().Throw() - .WithMessage("*会员ID必须大于0*"); - } - - [Fact] - public void Create_WithInvalidTenantId_ShouldThrowException() - { - var action = () => PointsAccount.Create(1, 0); - - action.Should().Throw() - .WithMessage("*租户ID必须大于0*"); - } - - [Fact] - public void AddPoints_ShouldIncreaseTotalPoints() - { - var account = PointsAccount.Create(1, 100); - - account.AddPoints(100, "REGISTER", "source_001", "注册赠送"); - - account.TotalPoints.Should().Be(100); - account.AvailablePoints.Should().Be(100); - account.Transactions.Should().HaveCount(1); - } - - [Fact] - public void AddPoints_WithInvalidPoints_ShouldThrowException() - { - var account = PointsAccount.Create(1, 100); - - var action = () => account.AddPoints(0, "REGISTER", "source_001"); - - action.Should().Throw() - .WithMessage("*积分必须大于0*"); - } - - [Fact] - public void DeductPoints_ShouldDecreaseTotalPoints() - { - var account = PointsAccount.Create(1, 100); - account.AddPoints(100, "REGISTER", "source_001"); - - var result = account.DeductPoints(30, "EXCHANGE", "order_001"); - - result.Should().BeTrue(); - account.TotalPoints.Should().Be(70); - account.AvailablePoints.Should().Be(70); - } - - [Fact] - public void DeductPoints_WithInsufficientPoints_ShouldReturnFalse() - { - var account = PointsAccount.Create(1, 100); - account.AddPoints(50, "REGISTER", "source_001"); - - var result = account.DeductPoints(100, "EXCHANGE", "order_001"); - - result.Should().BeFalse(); - account.TotalPoints.Should().Be(50); - } - - [Fact] - public void FreezePoints_ShouldIncreaseFrozenPoints() - { - var account = PointsAccount.Create(1, 100); - account.AddPoints(100, "REGISTER", "source_001"); - - var result = account.FreezePoints(30); - - result.Should().BeTrue(); - account.FrozenPoints.Should().Be(30); - account.AvailablePoints.Should().Be(70); - } - - [Fact] - public void FreezePoints_WithInsufficientPoints_ShouldReturnFalse() - { - var account = PointsAccount.Create(1, 100); - account.AddPoints(50, "REGISTER", "source_001"); - - var result = account.FreezePoints(100); - - result.Should().BeFalse(); - account.FrozenPoints.Should().Be(0); - } - - [Fact] - public void UnfreezePoints_ShouldDecreaseFrozenPoints() - { - var account = PointsAccount.Create(1, 100); - account.AddPoints(100, "REGISTER", "source_001"); - account.FreezePoints(50); - - var result = account.UnfreezePoints(30); - - result.Should().BeTrue(); - account.FrozenPoints.Should().Be(20); - account.AvailablePoints.Should().Be(80); - } - - [Fact] - public void UseFrozenPoints_ShouldDecreaseBothFrozenAndTotalPoints() - { - var account = PointsAccount.Create(1, 100); - account.AddPoints(100, "REGISTER", "source_001"); - account.FreezePoints(50); - - var result = account.UseFrozenPoints(30); - - result.Should().BeTrue(); - account.FrozenPoints.Should().Be(20); - account.TotalPoints.Should().Be(70); - } -} diff --git a/test/Fengling.Member.Domain.Tests/Aggregates/PointsRuleModel/PointsRuleConditionTests.cs b/test/Fengling.Member.Domain.Tests/Aggregates/PointsRuleModel/PointsRuleConditionTests.cs deleted file mode 100644 index 8d5e395..0000000 --- a/test/Fengling.Member.Domain.Tests/Aggregates/PointsRuleModel/PointsRuleConditionTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -using FluentAssertions; -using Fengling.Member.Domain.Aggregates.PointsRuleModel; -using Fengling.Member.Domain.Aggregates.PointsRuleModel.Enums; - -namespace Fengling.Member.Domain.Tests.Aggregates.PointsRuleModel; - -public class PointsRuleConditionTests -{ - [Fact] - public void Create_ShouldInitializeWithCorrectValues() - { - var rule = PointsRule.Create( - "消费送积分", - "CONSUMPTION_POINTS", - RuleType.PriceWeighted, - 50, - 30); - - var condition = PointsRuleCondition.Create( - rule.Id, - DimensionType.Product, - "100", - ">="); - - condition.RuleId.Should().Be(rule.Id); - condition.DimensionType.Should().Be(DimensionType.Product); - condition.DimensionValue.Should().Be("100"); - condition.Operator.Should().Be(">="); - } - - [Fact] - public void Create_WithNullOperator_ShouldAllowNullOperator() - { - var rule = PointsRule.Create( - "注册送积分", - "REGISTER_POINTS", - RuleType.FixedValue, - 100, - 30); - - var condition = PointsRuleCondition.Create( - rule.Id, - DimensionType.Dealer, - "1"); - - condition.Operator.Should().BeNull(); - } -} diff --git a/test/Fengling.Member.Domain.Tests/Aggregates/PointsRuleModel/PointsRuleTests.cs b/test/Fengling.Member.Domain.Tests/Aggregates/PointsRuleModel/PointsRuleTests.cs deleted file mode 100644 index eb02891..0000000 --- a/test/Fengling.Member.Domain.Tests/Aggregates/PointsRuleModel/PointsRuleTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using FluentAssertions; -using Fengling.Member.Domain.Aggregates.PointsRuleModel; -using Fengling.Member.Domain.Aggregates.PointsRuleModel.Enums; - -namespace Fengling.Member.Domain.Tests.Aggregates.PointsRuleModel; - -public class PointsRuleTests -{ - [Fact] - public void Create_ShouldInitializeWithDefaultValues() - { - var rule = PointsRule.Create( - "注册送积分", - "REGISTER_POINTS", - RuleType.FixedValue, - 100, - 30); - - rule.Name.Should().Be("注册送积分"); - rule.Code.Should().Be("REGISTER_POINTS"); - rule.RuleType.Should().Be(RuleType.FixedValue); - rule.BasePoints.Should().Be(100); - rule.ValidityDays.Should().Be(30); - rule.Priority.Should().Be(0); - rule.CalculationMode.Should().Be(CalculationMode.Synchronous); - rule.WeightFactor.Should().BeNull(); - rule.IsActive.Should().BeTrue(); - rule.Conditions.Should().BeEmpty(); - } - - [Fact] - public void Create_WithAllParameters_ShouldSetAllProperties() - { - var rule = PointsRule.Create( - "消费送积分", - "CONSUMPTION_POINTS", - RuleType.PriceWeighted, - 50, - 7, - 10, - CalculationMode.Asynchronous, - 1.5m); - - rule.Name.Should().Be("消费送积分"); - rule.Code.Should().Be("CONSUMPTION_POINTS"); - rule.RuleType.Should().Be(RuleType.PriceWeighted); - rule.BasePoints.Should().Be(50); - rule.ValidityDays.Should().Be(7); - rule.Priority.Should().Be(10); - rule.CalculationMode.Should().Be(CalculationMode.Asynchronous); - rule.WeightFactor.Should().Be(1.5m); - rule.IsActive.Should().BeTrue(); - } - - [Fact] - public void AddCondition_ShouldAddConditionToList() - { - var rule = PointsRule.Create( - "消费送积分", - "CONSUMPTION_POINTS", - RuleType.PriceWeighted, - 50, - 30); - - var condition = PointsRuleCondition.Create( - rule.Id, - DimensionType.Product, - "100", - "1"); - - rule.AddCondition(condition); - - rule.Conditions.Should().HaveCount(1); - rule.Conditions.First().Should().Be(condition); - } - - [Fact] - public void Deactivate_ShouldSetIsActiveToFalse() - { - var rule = PointsRule.Create( - "注册送积分", - "REGISTER_POINTS", - RuleType.FixedValue, - 100, - 30); - - rule.IsActive.Should().BeTrue(); - - rule.Deactivate(); - - rule.IsActive.Should().BeFalse(); - } -} diff --git a/test/Fengling.Member.Domain.Tests/Fengling.Member.Domain.Tests.csproj b/test/Fengling.Member.Domain.Tests/Fengling.Member.Domain.Tests.csproj deleted file mode 100644 index 4c0cae6..0000000 --- a/test/Fengling.Member.Domain.Tests/Fengling.Member.Domain.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - diff --git a/test/Fengling.Member.Domain.Tests/GlobalUsings.cs b/test/Fengling.Member.Domain.Tests/GlobalUsings.cs deleted file mode 100644 index 90b6b1e..0000000 --- a/test/Fengling.Member.Domain.Tests/GlobalUsings.cs +++ /dev/null @@ -1,2 +0,0 @@ -global using Xunit; -global using NetCorePal.Extensions.Primitives; \ No newline at end of file diff --git a/test/Fengling.Member.Infrastructure.Tests/Fengling.Member.Infrastructure.Tests.csproj b/test/Fengling.Member.Infrastructure.Tests/Fengling.Member.Infrastructure.Tests.csproj deleted file mode 100644 index 36c96f9..0000000 --- a/test/Fengling.Member.Infrastructure.Tests/Fengling.Member.Infrastructure.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - diff --git a/test/Fengling.Member.Infrastructure.Tests/GlobalUsings.cs b/test/Fengling.Member.Infrastructure.Tests/GlobalUsings.cs deleted file mode 100644 index 8c927eb..0000000 --- a/test/Fengling.Member.Infrastructure.Tests/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/test/Fengling.Member.Web.Tests/AssemblyInfo.cs b/test/Fengling.Member.Web.Tests/AssemblyInfo.cs deleted file mode 100644 index 5a2b23b..0000000 --- a/test/Fengling.Member.Web.Tests/AssemblyInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -//Ordering Tests In Collections see:https://fast-endpoints.com/docs/integration-unit-testing#ordering-tests-in-collections -// [assembly: EnableAdvancedTesting] - -// can capture standard output and standard error -// [assembly: CaptureConsole] - -// will capture output from Debug and Trace -// [assembly: CaptureTrace] \ No newline at end of file diff --git a/test/Fengling.Member.Web.Tests/Fengling.Member.Web.Tests.csproj b/test/Fengling.Member.Web.Tests/Fengling.Member.Web.Tests.csproj deleted file mode 100644 index 00cdd81..0000000 --- a/test/Fengling.Member.Web.Tests/Fengling.Member.Web.Tests.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - \ No newline at end of file diff --git a/test/Fengling.Member.Web.Tests/Fixtures/WebAppFixture.cs b/test/Fengling.Member.Web.Tests/Fixtures/WebAppFixture.cs deleted file mode 100644 index 742f24e..0000000 --- a/test/Fengling.Member.Web.Tests/Fixtures/WebAppFixture.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Testcontainers.PostgreSql; -using Testcontainers.RabbitMq; -using Testcontainers.Redis; -using Microsoft.AspNetCore.Hosting; -using Fengling.Member.Infrastructure; -using Microsoft.EntityFrameworkCore; - -namespace Fengling.Member.Web.Tests.Fixtures; - -public class WebAppFixture : AppFixture -{ - private RedisContainer _redisContainer = null!; - private RabbitMqContainer _rabbitMqContainer = null!; - private PostgreSqlContainer _databaseContainer = null!; - - protected override async ValueTask PreSetupAsync() - { - _redisContainer = new RedisBuilder() - .WithCommand("--databases", "1024").Build(); - _rabbitMqContainer = new RabbitMqBuilder() - .WithUsername("guest").WithPassword("guest").Build(); - _databaseContainer = new PostgreSqlBuilder() - .WithUsername("postgres").WithPassword("123456") - .WithEnvironment("TZ", "Asia/Shanghai") - .WithDatabase("postgres").Build(); - - var tasks = new List { _redisContainer.StartAsync() }; - tasks.Add(_rabbitMqContainer.StartAsync()); - tasks.Add(_databaseContainer.StartAsync()); - await Task.WhenAll(tasks); - await CreateVisualHostAsync("/"); - } - - protected override void ConfigureApp(IWebHostBuilder a) - { - a.UseSetting("ConnectionStrings:Redis", - _redisContainer.GetConnectionString()); - a.UseSetting("ConnectionStrings:PostgreSQL", - _databaseContainer.GetConnectionString()); - a.UseSetting("RabbitMQ:Port", _rabbitMqContainer.GetMappedPublicPort(5672).ToString()); - a.UseSetting("RabbitMQ:UserName", "guest"); - a.UseSetting("RabbitMQ:Password", "guest"); - a.UseSetting("RabbitMQ:VirtualHost", "/"); - a.UseSetting("RabbitMQ:HostName", _rabbitMqContainer.Hostname); - a.UseEnvironment("Development"); - } - - private async Task CreateVisualHostAsync(string visualHost) - { - await _rabbitMqContainer.ExecAsync(["rabbitmqctl", "add_vhost", visualHost]); - await _rabbitMqContainer.ExecAsync(["rabbitmqctl", "set_permissions", "-p", visualHost, "guest", ".*", ".*", ".*" - ]); - } -} diff --git a/test/Fengling.Member.Web.Tests/Fixtures/WebAppTestCollection.cs b/test/Fengling.Member.Web.Tests/Fixtures/WebAppTestCollection.cs deleted file mode 100644 index d1446d8..0000000 --- a/test/Fengling.Member.Web.Tests/Fixtures/WebAppTestCollection.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Fengling.Member.Web.Tests.Fixtures; - -[CollectionDefinition(Name)] -public class WebAppTestCollection : TestCollection -{ - public const string Name = nameof(WebAppTestCollection); -} diff --git a/test/Fengling.Member.Web.Tests/GlobalUsings.cs b/test/Fengling.Member.Web.Tests/GlobalUsings.cs deleted file mode 100644 index b8bfa25..0000000 --- a/test/Fengling.Member.Web.Tests/GlobalUsings.cs +++ /dev/null @@ -1,9 +0,0 @@ -global using Xunit; -global using Fengling.Member.Web.Tests.Fixtures; -global using FastEndpoints.Testing; -global using FastEndpoints; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.DependencyInjection.Extensions; -global using NetCorePal.Extensions.NewtonsoftJson; -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.Logging; diff --git a/test/Fengling.Member.Web.Tests/xunit.runner.json b/test/Fengling.Member.Web.Tests/xunit.runner.json deleted file mode 100644 index c7bb228..0000000 --- a/test/Fengling.Member.Web.Tests/xunit.runner.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "parallelizeAssembly": true, - "parallelizeTestCollections": false, - "diagnosticMessages": false -} \ No newline at end of file