refactor: major project restructuring and cleanup

Changes:

- Remove deprecated Fengling.Activity and YarpGateway.Admin projects

- Add points processing services with distributed lock support

- Update Vben frontend with gateway management pages

- Add gateway config controller and database listener

- Update routing to use header-mixed-nav layout

- Add comprehensive test suites for Member services

- Add YarpGateway integration tests

- Update package versions in Directory.Packages.props

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
sam 2026-02-15 10:34:07 +08:00
parent 087948a8e1
commit 92247346dd
34 changed files with 1222 additions and 467 deletions

View File

@ -123,6 +123,7 @@
<PackageVersion Include="NetCorePal.Aspire.Hosting.OpenGauss" Version="$(NetCorePalAspireVersion)" />
<PackageVersion Include="NetCorePal.Aspire.Hosting.MongoDB" Version="$(NetCorePalAspireVersion)" />
<!-- Testing packages -->
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Testcontainers" Version="$(TestcontainersVersion)" />
<PackageVersion Include="Testcontainers.MySql" Version="$(TestcontainersVersion)" />

View File

@ -31,6 +31,7 @@ public class ProcessExpiredPointsCommandHandler : IRequestHandler<ProcessExpired
var deduction = PointsTransaction.Create(
transaction.PointsAccountId,
transaction.MemberId,
transaction.TenantId,
-transaction.Points,
"Expired",
$"来源交易: {transaction.Id}",

View File

@ -0,0 +1,54 @@
using MediatR;
namespace Fengling.Member.Application.Events;
/// <summary>
/// 积分变动事件
/// </summary>
public class PointsChangedEvent : INotification
{
/// <summary>
/// 码ID用于幂等性检查
/// </summary>
public string CodeId { get; set; } = string.Empty;
/// <summary>
/// 会员ID
/// </summary>
public long MemberId { get; set; }
/// <summary>
/// 租户ID
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 交易类型
/// </summary>
public string TransactionType { get; set; } = string.Empty;
/// <summary>
/// 积分数(正数增加,负数扣减)
/// </summary>
public int Points { get; set; }
/// <summary>
/// 来源ID
/// </summary>
public string? SourceId { get; set; }
/// <summary>
/// 备注
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 变动后的总积分
/// </summary>
public int TotalPoints { get; set; }
/// <summary>
/// 变动时间
/// </summary>
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,77 @@
using MediatR;
using Fengling.Member.Domain.Aggregates.PointsModel;
using Microsoft.Extensions.Logging;
namespace Fengling.Member.Application.Events;
/// <summary>
/// 积分变动事件处理器 - 负责将积分变动持久化到数据库
/// </summary>
public class PointsChangedEventHandler : INotificationHandler<PointsChangedEvent>
{
private readonly IPointsHistoryRepository _historyRepository;
private readonly ILogger<PointsChangedEventHandler> _logger;
public PointsChangedEventHandler(
IPointsHistoryRepository historyRepository,
ILogger<PointsChangedEventHandler> 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;
}
}
}

View File

@ -11,6 +11,7 @@
<PackageReference Include="FluentValidation.AspNetCore" />
<PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Fengling.Member.Domain\Fengling.Member.Domain.csproj" />

View File

@ -0,0 +1,99 @@
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Fengling.Member.Application.Services;
/// <summary>
/// 基于 Redis 的分布式锁实现
/// </summary>
public class CodeDistributedLock : ICodeDistributedLock
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<CodeDistributedLock> _logger;
private const string LockKeyPrefix = "points:lock:";
private readonly TimeSpan _defaultTtl = TimeSpan.FromSeconds(10);
public CodeDistributedLock(IConnectionMultiplexer redis, ILogger<CodeDistributedLock> logger)
{
_redis = redis;
_logger = logger;
}
public async Task<LockAcquisitionResult> 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);
}
}
}

View File

@ -0,0 +1,44 @@
namespace Fengling.Member.Application.Services;
/// <summary>
/// 分布式锁获取结果
/// </summary>
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
};
}
/// <summary>
/// 分布式锁接口
/// </summary>
public interface ICodeDistributedLock
{
/// <summary>
/// 获取分布式锁
/// </summary>
/// <param name="codeId">码ID</param>
/// <param name="ttl">锁过期时间默认10秒</param>
/// <returns>获取结果</returns>
Task<LockAcquisitionResult> AcquireAsync(string codeId, TimeSpan? ttl = null);
/// <summary>
/// 释放分布式锁
/// </summary>
/// <param name="codeId">码ID</param>
/// <param name="lockValue">锁值</param>
Task ReleaseAsync(string codeId, string lockValue);
}

View File

@ -0,0 +1,64 @@
namespace Fengling.Member.Application.Services;
/// <summary>
/// 积分账户缓存模型
/// </summary>
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;
}
/// <summary>
/// 积分账户缓存接口
/// </summary>
public interface IPointsAccountCache
{
/// <summary>
/// 获取缓存的积分账户
/// </summary>
Task<PointsAccountCacheModel?> GetAsync(long memberId);
/// <summary>
/// 设置积分账户缓存
/// </summary>
Task SetAsync(long memberId, PointsAccountCacheModel account, TimeSpan? ttl = null);
/// <summary>
/// 增加积分
/// </summary>
Task<int> AddPointsAsync(long memberId, int points);
/// <summary>
/// 扣减积分
/// </summary>
Task<(bool Success, int RemainingPoints)> DeductPointsAsync(long memberId, int points);
/// <summary>
/// 冻结积分
/// </summary>
Task<bool> FreezePointsAsync(long memberId, int points);
/// <summary>
/// 解冻积分
/// </summary>
Task<bool> UnfreezePointsAsync(long memberId, int points);
/// <summary>
/// 移除缓存
/// </summary>
Task RemoveAsync(long memberId);
/// <summary>
/// 检查积分码是否已处理(幂等性检查)
/// </summary>
Task<bool> IsCodeProcessedAsync(string codeId);
/// <summary>
/// 标记积分码已处理
/// </summary>
Task MarkCodeProcessedAsync(string codeId, TimeSpan? ttl = null);
}

View File

@ -0,0 +1,89 @@
using Fengling.Member.Application.Dtos;
namespace Fengling.Member.Application.Services;
/// <summary>
/// 积分处理请求
/// </summary>
public class PointsProcessRequest
{
/// <summary>
/// 码ID用于幂等性检查和分布式锁
/// </summary>
public string CodeId { get; set; } = string.Empty;
/// <summary>
/// 会员ID
/// </summary>
public long MemberId { get; set; }
/// <summary>
/// 交易类型
/// </summary>
public string TransactionType { get; set; } = string.Empty;
/// <summary>
/// 积分数(增加或扣减)
/// </summary>
public int Points { get; set; }
/// <summary>
/// 扣减类型true=增加积分false=扣减积分
/// </summary>
public bool IsAddition { get; set; } = true;
/// <summary>
/// 来源ID
/// </summary>
public string? SourceId { get; set; }
/// <summary>
/// 备注
/// </summary>
public string? Remark { get; set; }
}
/// <summary>
/// 积分处理结果
/// </summary>
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
};
}
/// <summary>
/// 积分处理服务接口
/// </summary>
public interface IPointsProcessingService
{
/// <summary>
/// 处理积分(增加或扣减)
/// </summary>
Task<PointsProcessResult> ProcessAsync(PointsProcessRequest request, CancellationToken ct = default);
/// <summary>
/// 获取积分余额
/// </summary>
Task<PointsAccountCacheModel?> GetBalanceAsync(long memberId);
}

View File

@ -0,0 +1,248 @@
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Fengling.Member.Application.Services;
/// <summary>
/// Redis 积分账户缓存实现
/// </summary>
public class PointsAccountCache : IPointsAccountCache
{
private readonly IConnectionMultiplexer _redis;
private readonly ILogger<PointsAccountCache> _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<PointsAccountCache> logger)
{
_redis = redis;
_logger = logger;
}
public async Task<PointsAccountCacheModel?> 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<int> 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<bool> 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<bool> 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<bool> 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])
};
}
}

View File

@ -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;
/// <summary>
/// 积分处理服务实现
/// 使用分布式锁 + Redis 缓存实现高性能积分处理
/// </summary>
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<PointsProcessingService> _logger;
public PointsProcessingService(
IPointsAccountCache cache,
ICodeDistributedLock distributedLock,
IPointsHistoryRepository historyRepository,
IMediator mediator,
ITenantAccessor tenantAccessor,
ILogger<PointsProcessingService> logger)
{
_cache = cache;
_lock = distributedLock;
_historyRepository = historyRepository;
_mediator = mediator;
_tenantAccessor = tenantAccessor;
_logger = logger;
}
public async Task<PointsProcessResult> 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<PointsAccountCacheModel?> GetBalanceAsync(long memberId)
{
return await _cache.GetAsync(memberId);
}
}

View File

@ -0,0 +1,38 @@
using Fengling.Member.Domain.Aggregates.PointsModel;
namespace Fengling.Member.Domain.Aggregates.PointsModel;
/// <summary>
/// 积分历史仓储接口
/// </summary>
public interface IPointsHistoryRepository
{
/// <summary>
/// 检查 SourceId 是否已存在(幂等性检查)
/// </summary>
Task<bool> ExistsBySourceIdAsync(string sourceId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取会员的积分明细(分页)
/// </summary>
Task<IEnumerable<PointsTransaction>> GetByMemberIdAsync(
long memberId,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
/// <summary>
/// 统计会员的积分明细数量
/// </summary>
Task<int> CountByMemberIdAsync(long memberId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取积分明细
/// </summary>
Task<PointsTransaction?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// 添加积分明细
/// </summary>
Task AddAsync(PointsTransaction transaction, CancellationToken cancellationToken = default);
}

View File

@ -48,6 +48,7 @@ public class PointsAccount : Entity<long>, IAggregateRoot
var transaction = PointsTransaction.Create(
Id,
MemberId,
TenantId,
points,
transactionType,
sourceId,
@ -60,7 +61,7 @@ public class PointsAccount : Entity<long>, 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<long>, IAggregateRoot
var transaction = PointsTransaction.Create(
Id,
MemberId,
TenantId,
points,
transactionType,
sourceId,
@ -86,7 +88,7 @@ public class PointsAccount : Entity<long>, 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;
}

View File

@ -4,6 +4,7 @@ public class PointsTransaction : Entity<long>
{
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<long>
public static PointsTransaction Create(
long pointsAccountId,
long memberId,
long tenantId,
int points,
string transactionType,
string sourceId,
@ -34,6 +36,7 @@ public class PointsTransaction : Entity<long>
{
PointsAccountId = pointsAccountId,
MemberId = memberId,
TenantId = tenantId,
Points = points,
TransactionType = transactionType,
SourceId = sourceId,

View File

@ -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;

View File

@ -0,0 +1,21 @@
namespace Fengling.Member.Infrastructure;
/// <summary>
/// 租户访问器接口
/// 用于从不同来源获取当前请求的 TenantId
/// - HTTP 请求:从 JWT Claim 获取
/// - 消息队列消费者:从消息 Header 获取
/// </summary>
public interface ITenantAccessor
{
/// <summary>
/// 获取当前租户ID
/// </summary>
/// <returns>租户ID如果无法获取则返回 null</returns>
long? GetTenantId();
/// <summary>
/// 尝试获取当前租户ID
/// </summary>
bool TryGetTenantId(out long tenantId);
}

View File

@ -0,0 +1,38 @@
using Fengling.Member.Domain.Aggregates.PointsModel;
namespace Fengling.Member.Infrastructure.Repositories;
/// <summary>
/// 积分历史仓储接口
/// </summary>
public interface IPointsHistoryRepository
{
/// <summary>
/// 检查 SourceId 是否已存在(幂等性检查)
/// </summary>
Task<bool> ExistsBySourceIdAsync(string sourceId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取会员的积分明细(分页)
/// </summary>
Task<IEnumerable<PointsTransaction>> GetByMemberIdAsync(
long memberId,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default);
/// <summary>
/// 统计会员的积分明细数量
/// </summary>
Task<int> CountByMemberIdAsync(long memberId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取积分明细
/// </summary>
Task<PointsTransaction?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary>
/// 添加积分明细
/// </summary>
Task AddAsync(PointsTransaction transaction, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,54 @@
using Fengling.Member.Domain.Aggregates.PointsModel;
using Microsoft.EntityFrameworkCore;
namespace Fengling.Member.Infrastructure.Repositories;
/// <summary>
/// 积分历史仓储实现
/// </summary>
public class PointsHistoryRepository : IPointsHistoryRepository
{
private readonly ApplicationDbContext _context;
public PointsHistoryRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<bool> ExistsBySourceIdAsync(string sourceId, CancellationToken cancellationToken = default)
{
return await _context.PointsTransactions
.AnyAsync(t => t.SourceId == sourceId, cancellationToken);
}
public async Task<IEnumerable<PointsTransaction>> 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<int> CountByMemberIdAsync(long memberId, CancellationToken cancellationToken = default)
{
return await _context.PointsTransactions
.CountAsync(t => t.MemberId == memberId, cancellationToken);
}
public async Task<PointsTransaction?> 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);
}
}

View File

@ -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<GetPointsBalanceRequest, GetPointsBalanceResponse>
{
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<GetPointsHistoryRequest, GetPointsHistoryResponse>
{
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<PointsTransactionDto> 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<ProcessPointsRequest, ProcessPointsResponse>
{
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; }
}

View File

@ -0,0 +1,43 @@
using Fengling.Member.Infrastructure;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
namespace Fengling.Member.Web;
/// <summary>
/// HTTP 上下文租户访问器
/// 从 JWT Claim 中获取 tenant_id
/// </summary>
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;
}
}

View File

@ -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<ITenantAccessor, HttpContextTenantAccessor>();
#endregion
var app = builder.Build();

View File

@ -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<ArgumentException>()
.WithMessage("*会员ID必须大于0*");
}
[Fact]
public void Create_WithInvalidTenantId_ShouldThrowException()
{
var action = () => PointsAccount.Create(1, 0);
action.Should().Throw<ArgumentException>()
.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<ArgumentException>()
.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);
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Fengling.Member.Domain\Fengling.Member.Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -1,2 +0,0 @@
global using Xunit;
global using NetCorePal.Extensions.Primitives;

View File

@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Fengling.Member.Infrastructure\Fengling.Member.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -1 +0,0 @@
global using Xunit;

View File

@ -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]

View File

@ -1,41 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<None Remove="appsettings.Development.json" />
<None Remove="appsettings.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Moq" />
<PackageReference Include="Testcontainers" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers.RabbitMq" />
<PackageReference Include="Testcontainers.Redis" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.CodeAnalysis" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FastEndpoints.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Fengling.Member.Web\Fengling.Member.Web.csproj" />
</ItemGroup>
</Project>

View File

@ -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<Program>
{
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<Task> { _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", ".*", ".*", ".*"
]);
}
}

View File

@ -1,7 +0,0 @@
namespace Fengling.Member.Web.Tests.Fixtures;
[CollectionDefinition(Name)]
public class WebAppTestCollection : TestCollection<WebAppFixture>
{
public const string Name = nameof(WebAppTestCollection);
}

View File

@ -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;

View File

@ -1,5 +0,0 @@
{
"parallelizeAssembly": true,
"parallelizeTestCollections": false,
"diagnosticMessages": false
}