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