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:
parent
087948a8e1
commit
92247346dd
@ -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)" />
|
||||
|
||||
@ -31,6 +31,7 @@ public class ProcessExpiredPointsCommandHandler : IRequestHandler<ProcessExpired
|
||||
var deduction = PointsTransaction.Create(
|
||||
transaction.PointsAccountId,
|
||||
transaction.MemberId,
|
||||
transaction.TenantId,
|
||||
-transaction.Points,
|
||||
"Expired",
|
||||
$"来源交易: {transaction.Id}",
|
||||
|
||||
54
src/Fengling.Member.Application/Events/PointsChangedEvent.cs
Normal file
54
src/Fengling.Member.Application/Events/PointsChangedEvent.cs
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
248
src/Fengling.Member.Application/Services/PointsAccountCache.cs
Normal file
248
src/Fengling.Member.Application/Services/PointsAccountCache.cs
Normal 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])
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
21
src/Fengling.Member.Infrastructure/ITenantAccessor.cs
Normal file
21
src/Fengling.Member.Infrastructure/ITenantAccessor.cs
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
43
src/Fengling.Member.Web/HttpContextTenantAccessor.cs
Normal file
43
src/Fengling.Member.Web/HttpContextTenantAccessor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -1,2 +0,0 @@
|
||||
global using Xunit;
|
||||
global using NetCorePal.Extensions.Primitives;
|
||||
@ -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>
|
||||
@ -1 +0,0 @@
|
||||
global using Xunit;
|
||||
@ -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]
|
||||
@ -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>
|
||||
@ -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", ".*", ".*", ".*"
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
namespace Fengling.Member.Web.Tests.Fixtures;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public class WebAppTestCollection : TestCollection<WebAppFixture>
|
||||
{
|
||||
public const string Name = nameof(WebAppTestCollection);
|
||||
}
|
||||
@ -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;
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"parallelizeAssembly": true,
|
||||
"parallelizeTestCollections": false,
|
||||
"diagnosticMessages": false
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user