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.OpenGauss" Version="$(NetCorePalAspireVersion)" />
<PackageVersion Include="NetCorePal.Aspire.Hosting.MongoDB" Version="$(NetCorePalAspireVersion)" /> <PackageVersion Include="NetCorePal.Aspire.Hosting.MongoDB" Version="$(NetCorePalAspireVersion)" />
<!-- Testing packages --> <!-- Testing packages -->
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="Moq" Version="4.20.72" /> <PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Testcontainers" Version="$(TestcontainersVersion)" /> <PackageVersion Include="Testcontainers" Version="$(TestcontainersVersion)" />
<PackageVersion Include="Testcontainers.MySql" Version="$(TestcontainersVersion)" /> <PackageVersion Include="Testcontainers.MySql" Version="$(TestcontainersVersion)" />

View File

@ -31,6 +31,7 @@ public class ProcessExpiredPointsCommandHandler : IRequestHandler<ProcessExpired
var deduction = PointsTransaction.Create( var deduction = PointsTransaction.Create(
transaction.PointsAccountId, transaction.PointsAccountId,
transaction.MemberId, transaction.MemberId,
transaction.TenantId,
-transaction.Points, -transaction.Points,
"Expired", "Expired",
$"来源交易: {transaction.Id}", $"来源交易: {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="FluentValidation.AspNetCore" />
<PackageReference Include="MediatR" /> <PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Fengling.Member.Domain\Fengling.Member.Domain.csproj" /> <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( var transaction = PointsTransaction.Create(
Id, Id,
MemberId, MemberId,
TenantId,
points, points,
transactionType, transactionType,
sourceId, sourceId,
@ -60,7 +61,7 @@ public class PointsAccount : Entity<long>, IAggregateRoot
UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow;
Version++; 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) 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( var transaction = PointsTransaction.Create(
Id, Id,
MemberId, MemberId,
TenantId,
points, points,
transactionType, transactionType,
sourceId, sourceId,
@ -86,7 +88,7 @@ public class PointsAccount : Entity<long>, IAggregateRoot
UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow;
Version++; Version++;
AddDomainEvent(new PointsChangedEvent(Id, MemberId, -points, TotalPoints, transactionType)); AddDomainEvent(new PointsChangedEvent(Id, MemberId, TenantId, -points, TotalPoints, transactionType, sourceId, remark));
return true; return true;
} }

View File

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

View File

@ -3,7 +3,10 @@ namespace Fengling.Member.Domain.Events.Points;
public record PointsChangedEvent( public record PointsChangedEvent(
long AccountId, long AccountId,
long MemberId, long MemberId,
long TenantId,
int ChangedPoints, int ChangedPoints,
int NewBalance, int NewBalance,
string TransactionType string TransactionType,
string? SourceId,
string? Remark
) : IDomainEvent; ) : 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 MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Fengling.Member.Application.Commands.Points; using Fengling.Member.Application.Commands.Points;
using Fengling.Member.Application.Services;
using Fengling.Member.Domain.Aggregates.PointsModel;
namespace Fengling.Member.Web.Endpoints.v1; namespace Fengling.Member.Web.Endpoints.v1;
@ -66,3 +68,202 @@ public class AddPointsResponse
public int TotalPoints { get; set; } public int TotalPoints { get; set; }
public DateTime TransactionAt { 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.Services;
using Fengling.Member.Application.Commands.Points; using Fengling.Member.Application.Commands.Points;
using Fengling.Member.Web.Endpoints.v1; using Fengling.Member.Web.Endpoints.v1;
using Fengling.Member.Web;
using Fengling.Member.Domain.Repositories; using Fengling.Member.Domain.Repositories;
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
@ -190,6 +191,13 @@ try
#endregion #endregion
#region Tenant Accessor
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantAccessor, HttpContextTenantAccessor>();
#endregion
var app = builder.Build(); 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
}