diff --git a/DatabaseScripts/message_outbox.sql b/DatabaseScripts/message_outbox.sql
new file mode 100644
index 0000000..1d7b8bd
--- /dev/null
+++ b/DatabaseScripts/message_outbox.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS `Message_Outbox` (
+ `Id` bigint NOT NULL,
+ `Exchange` varchar(200) NOT NULL,
+ `Queue` varchar(200) NOT NULL,
+ `RoutingKey` varchar(200) NOT NULL,
+ `Payload` json NOT NULL,
+ `RetryCount` int NOT NULL DEFAULT 0,
+ `NextRetryAt` datetime NULL,
+ `SentAt` datetime NULL,
+ `LastError` varchar(2000) NULL,
+ `BusinessType` varchar(100) NOT NULL,
+ `BusinessId` bigint NOT NULL,
+ `Status` int NOT NULL DEFAULT 0,
+ `IsDeleted` bit NOT NULL DEFAULT b'0',
+ `CreatedBy` varchar(100) NOT NULL,
+ `CreatedAt` datetime NOT NULL,
+ `UpdatedBy` varchar(100) NULL,
+ `UpdatedAt` datetime NULL,
+ PRIMARY KEY (`Id`),
+ KEY `idx_message_outbox_status_next_retry` (`Status`, `NextRetryAt`, `CreatedAt`),
+ KEY `idx_message_outbox_business` (`BusinessType`, `BusinessId`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/DatabaseScripts/stability_unique_constraints.sql b/DatabaseScripts/stability_unique_constraints.sql
new file mode 100644
index 0000000..7cbad47
--- /dev/null
+++ b/DatabaseScripts/stability_unique_constraints.sql
@@ -0,0 +1,5 @@
+ALTER TABLE `CheckIn_Record`
+ADD UNIQUE KEY `uk_checkin_record_user_date_type_deleted` (`UserId`, `CheckInDate`, `Type`, `IsDeleted`);
+
+ALTER TABLE `User_Bag`
+ADD UNIQUE KEY `uk_user_bag_available_item` (`UserId`, `ItemId`, `Status`, `IsDeleted`);
diff --git a/QYZH.InteractiveMagazine.IService/IMessagePublishService.cs b/QYZH.InteractiveMagazine.IService/IMessagePublishService.cs
new file mode 100644
index 0000000..e3c20e2
--- /dev/null
+++ b/QYZH.InteractiveMagazine.IService/IMessagePublishService.cs
@@ -0,0 +1,25 @@
+using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
+using QYZH.InteractiveMagazine.Models.Entity;
+
+namespace QYZH.InteractiveMagazine.IService;
+
+///
+/// MQ消息发布服务。
+///
+public interface IMessagePublishService : IBaseService
+{
+ ///
+ /// 可靠发布消息,默认写入Outbox。
+ ///
+ Task PublishAsync(MessagePublishInput input, CancellationToken cancellationToken = default);
+
+ ///
+ /// 批量可靠发布消息,默认写入Outbox。
+ ///
+ Task PublishBatchAsync(IEnumerable> inputs, CancellationToken cancellationToken = default);
+
+ ///
+ /// 直接发布消息,不写Outbox。
+ ///
+ Task PublishDirectAsync(MessagePublishInput input, CancellationToken cancellationToken = default);
+}
diff --git a/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs b/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs
index 9ad59c6..7122f7f 100644
--- a/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs
+++ b/QYZH.InteractiveMagazine.Infrastructure/Extensions/DependencyInjectionExtensions.cs
@@ -106,6 +106,7 @@ public static class DependencyInjectionExtensions
var adminToken = await RedisHelper.GetAsync(JwtHelper.BuildAdminTokenKey(userId));
if (adminToken == currentToken)
{
+ await RefreshRedisTokenExpiryAsync(JwtHelper.BuildAdminTokenKey(userId), currentToken, jwtSettings);
return;
}
@@ -116,6 +117,7 @@ public static class DependencyInjectionExtensions
var wechatToken = await RedisHelper.GetAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, userId));
if (wechatToken == currentToken)
{
+ await RefreshRedisTokenExpiryAsync(JwtHelper.BuildWeChatTokenKey(wxUserId, userId), currentToken, jwtSettings);
return;
}
@@ -124,6 +126,14 @@ public static class DependencyInjectionExtensions
};
}
+ private static async Task RefreshRedisTokenExpiryAsync(string tokenKey, string currentToken, JwtSettings jwtSettings)
+ {
+ if (jwtSettings.ExpiryMinutes > 0)
+ {
+ await RedisHelper.SetAsync(tokenKey, currentToken, TimeSpan.FromMinutes(jwtSettings.ExpiryMinutes));
+ }
+ }
+
private static string? GetBearerToken(TokenValidatedContext context)
{
var authHeader = context.HttpContext.Request.Headers.Authorization.FirstOrDefault();
diff --git a/QYZH.InteractiveMagazine.Infrastructure/Middleware/JwtAutoRefreshMiddleware.cs b/QYZH.InteractiveMagazine.Infrastructure/Middleware/JwtAutoRefreshMiddleware.cs
index fc5aa69..fdcd34a 100644
--- a/QYZH.InteractiveMagazine.Infrastructure/Middleware/JwtAutoRefreshMiddleware.cs
+++ b/QYZH.InteractiveMagazine.Infrastructure/Middleware/JwtAutoRefreshMiddleware.cs
@@ -1,99 +1,21 @@
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Configuration;
-using QYZH.InteractiveMagazine.Infrastructure.Auth;
-using QYZH.InteractiveMagazine.Models.Settings;
-using System.IdentityModel.Tokens.Jwt;
-using System.Security.Claims;
namespace QYZH.InteractiveMagazine.Infrastructure.Middleware;
///
-/// Refreshes the Redis session TTL for the current JWT when it is still the active token.
+/// 保留兼容的JWT自动刷新中间件,实际刷新在认证成功后执行。
///
public class JwtAutoRefreshMiddleware
{
private readonly RequestDelegate _next;
- private readonly IConfiguration _configuration;
- public JwtAutoRefreshMiddleware(RequestDelegate next, IConfiguration configuration)
+ public JwtAutoRefreshMiddleware(RequestDelegate next)
{
_next = next;
- _configuration = configuration;
}
public async Task InvokeAsync(HttpContext context)
{
- var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
- if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
- {
- var token = authHeader.Substring("Bearer ".Length).Trim();
- await TryRefreshRedisTokenExpiryAsync(token);
- }
-
await _next(context);
}
-
- private async Task TryRefreshRedisTokenExpiryAsync(string token)
- {
- try
- {
- var tokenHandler = new JwtSecurityTokenHandler();
- if (tokenHandler.ReadToken(token) is not JwtSecurityToken jwtToken)
- {
- return;
- }
-
- if (jwtToken.ValidTo <= DateTime.UtcNow)
- {
- return;
- }
-
- var userId = GetClaimValue(jwtToken, ClaimTypes.NameIdentifier, JwtRegisteredClaimNames.NameId);
- if (string.IsNullOrEmpty(userId))
- {
- return;
- }
-
- var jwtSettings = _configuration.GetSection("JwtSettings").Get();
- if (jwtSettings == null || jwtSettings.ExpiryMinutes <= 0)
- {
- return;
- }
-
- var wxUserId = GetClaimValue(jwtToken, JwtHelper.WxUserIdClaimType);
- await RefreshRedisTokenExpiryAsync(wxUserId, userId, token, jwtSettings.ExpiryMinutes);
- }
- catch
- {
- // Ignore refresh failures. Authentication middleware will validate the request later.
- }
- }
-
- private static async Task RefreshRedisTokenExpiryAsync(string? wxUserId, string userId, string currentToken, int expiryMinutes)
- {
- var adminTokenKey = JwtHelper.BuildAdminTokenKey(userId);
- var adminToken = await RedisHelper.GetAsync(adminTokenKey);
- if (string.IsNullOrEmpty(wxUserId) && !string.IsNullOrEmpty(adminToken))
- {
- await RedisHelper.SetAsync(adminTokenKey, adminToken, TimeSpan.FromMinutes(expiryMinutes));
- return;
- }
-
- if (string.IsNullOrEmpty(wxUserId))
- {
- return;
- }
-
- var wechatTokenKey = JwtHelper.BuildWeChatTokenKey(wxUserId, userId);
- var wechatToken = await RedisHelper.GetAsync(wechatTokenKey);
- if (wechatToken == currentToken)
- {
- await RedisHelper.SetAsync(wechatTokenKey, currentToken, TimeSpan.FromMinutes(expiryMinutes));
- }
- }
-
- private static string? GetClaimValue(JwtSecurityToken jwtToken, params string[] claimTypes)
- {
- return jwtToken.Claims.FirstOrDefault(c => claimTypes.Contains(c.Type))?.Value;
- }
}
diff --git a/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs b/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs
index 7d1cbc1..61a2ae6 100644
--- a/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs
+++ b/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs
@@ -1,6 +1,7 @@
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
@@ -11,14 +12,16 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
{
private readonly IRabbitMQConnection _connection;
private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
private readonly JsonSerializerOptions options = new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
- public RabbitMQService(IRabbitMQConnection connection, IConfiguration configuration)
+ public RabbitMQService(IRabbitMQConnection connection, IConfiguration configuration, ILogger logger)
{
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_configuration = configuration;
+ _logger = logger;
}
@@ -55,12 +58,14 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
}
catch (OperationCanceledException ex)
{
- Console.WriteLine($"Operation was canceled: {ex.Message}");
+ _logger.LogWarning(ex, "RabbitMQ消息发送已取消,Exchange: {Exchange}, Queue: {Queue}, RoutingKey: {RoutingKey}",
+ param.Exchange, param.Queue, param.RoutingKey);
return false;
}
catch (Exception ex)
{
- Console.WriteLine($"An error occurred: {ex.Message}");
+ _logger.LogError(ex, "RabbitMQ消息发送失败,Exchange: {Exchange}, Queue: {Queue}, RoutingKey: {RoutingKey}",
+ param.Exchange, param.Queue, param.RoutingKey);
return false;
}
}
@@ -115,7 +120,7 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ
}
catch (Exception ex)
{
- Console.WriteLine($"An error occurred: {ex.Message}");
+ _logger.LogError(ex, "RabbitMQ批量消息发送失败");
// 回滚事务
try { await channel?.TxRollbackAsync(); } catch { /* 忽略回滚异常 */ }
return false;
diff --git a/QYZH.InteractiveMagazine.Models/Dto/RabbitMQ/MessagePublishInput.cs b/QYZH.InteractiveMagazine.Models/Dto/RabbitMQ/MessagePublishInput.cs
new file mode 100644
index 0000000..174bea4
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Dto/RabbitMQ/MessagePublishInput.cs
@@ -0,0 +1,38 @@
+namespace QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
+
+///
+/// MQ消息发布入参。
+///
+/// 消息数据类型。
+public class MessagePublishInput
+{
+ ///
+ /// 交换机。
+ ///
+ public string Exchange { get; set; } = string.Empty;
+
+ ///
+ /// 队列。
+ ///
+ public string Queue { get; set; } = string.Empty;
+
+ ///
+ /// 路由键。
+ ///
+ public string RoutingKey { get; set; } = string.Empty;
+
+ ///
+ /// 消息数据。
+ ///
+ public T Data { get; set; } = default!;
+
+ ///
+ /// 业务类型。
+ ///
+ public string BusinessType { get; set; } = string.Empty;
+
+ ///
+ /// 业务ID。
+ ///
+ public long BusinessId { get; set; }
+}
diff --git a/QYZH.InteractiveMagazine.Models/Dto/RabbitMQ/MessagePublishResult.cs b/QYZH.InteractiveMagazine.Models/Dto/RabbitMQ/MessagePublishResult.cs
new file mode 100644
index 0000000..f6b3b58
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Dto/RabbitMQ/MessagePublishResult.cs
@@ -0,0 +1,22 @@
+namespace QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
+
+///
+/// MQ消息发布结果。
+///
+public class MessagePublishResult
+{
+ ///
+ /// 是否成功。
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// Outbox消息ID。
+ ///
+ public List OutboxIds { get; set; } = [];
+
+ ///
+ /// 结果消息。
+ ///
+ public string Message { get; set; } = string.Empty;
+}
diff --git a/QYZH.InteractiveMagazine.Models/Entity/MessageOutbox.cs b/QYZH.InteractiveMagazine.Models/Entity/MessageOutbox.cs
new file mode 100644
index 0000000..89819bb
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Entity/MessageOutbox.cs
@@ -0,0 +1,60 @@
+using SqlSugar;
+
+namespace QYZH.InteractiveMagazine.Models.Entity;
+
+///
+/// 消息Outbox。
+///
+[SugarTable("Message_Outbox")]
+public partial class MessageOutbox : SqlSugarBaseEntity
+{
+ ///
+ /// 交换机。
+ ///
+ public string Exchange { get; set; } = string.Empty;
+
+ ///
+ /// 队列。
+ ///
+ public string Queue { get; set; } = string.Empty;
+
+ ///
+ /// 路由键。
+ ///
+ public string RoutingKey { get; set; } = string.Empty;
+
+ ///
+ /// 消息内容JSON。
+ ///
+ public string Payload { get; set; } = string.Empty;
+
+ ///
+ /// 重试次数。
+ ///
+ public int RetryCount { get; set; }
+
+ ///
+ /// 下次重试时间。
+ ///
+ public DateTime? NextRetryAt { get; set; }
+
+ ///
+ /// 发送成功时间。
+ ///
+ public DateTime? SentAt { get; set; }
+
+ ///
+ /// 最后错误。
+ ///
+ public string? LastError { get; set; }
+
+ ///
+ /// 业务类型。
+ ///
+ public string BusinessType { get; set; } = string.Empty;
+
+ ///
+ /// 业务ID。
+ ///
+ public long BusinessId { get; set; }
+}
diff --git a/QYZH.InteractiveMagazine.Models/Enum/MessageOutboxStatusEnum.cs b/QYZH.InteractiveMagazine.Models/Enum/MessageOutboxStatusEnum.cs
new file mode 100644
index 0000000..26deab1
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Enum/MessageOutboxStatusEnum.cs
@@ -0,0 +1,33 @@
+using System.ComponentModel;
+
+namespace QYZH.InteractiveMagazine.Models.Enum;
+
+///
+/// 消息Outbox状态。
+///
+public enum MessageOutboxStatusEnum
+{
+ ///
+ /// 待发送。
+ ///
+ [Description("待发送")]
+ Pending = 0,
+
+ ///
+ /// 已发送。
+ ///
+ [Description("已发送")]
+ Sent = 1,
+
+ ///
+ /// 发送失败待重试。
+ ///
+ [Description("发送失败待重试")]
+ Failed = 2,
+
+ ///
+ /// 已放弃。
+ ///
+ [Description("已放弃")]
+ Abandoned = 3
+}
diff --git a/QYZH.InteractiveMagazine.Models/Settings/HangfireStorageSettings.cs b/QYZH.InteractiveMagazine.Models/Settings/HangfireStorageSettings.cs
new file mode 100644
index 0000000..8530f75
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Settings/HangfireStorageSettings.cs
@@ -0,0 +1,12 @@
+namespace QYZH.InteractiveMagazine.Models.Settings;
+
+///
+/// Hangfire存储配置。
+///
+public class HangfireStorageSettings
+{
+ ///
+ /// 存储类型,当前默认 Memory,可配置为 Redis。
+ ///
+ public string StorageType { get; set; } = "Memory";
+}
diff --git a/QYZH.InteractiveMagazine.Models/Settings/RabbitMQRetrySettings.cs b/QYZH.InteractiveMagazine.Models/Settings/RabbitMQRetrySettings.cs
new file mode 100644
index 0000000..ab2d7ad
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Settings/RabbitMQRetrySettings.cs
@@ -0,0 +1,17 @@
+namespace QYZH.InteractiveMagazine.Models.Settings;
+
+///
+/// RabbitMQ重试配置。
+///
+public class RabbitMQRetrySettings
+{
+ ///
+ /// 最大重试次数。
+ ///
+ public int MaxRetryCount { get; set; } = 3;
+
+ ///
+ /// 重试延迟毫秒数。
+ ///
+ public int RetryDelayMilliseconds { get; set; } = 30000;
+}
diff --git a/QYZH.InteractiveMagazine.Models/Settings/SnowflakeSettings.cs b/QYZH.InteractiveMagazine.Models/Settings/SnowflakeSettings.cs
new file mode 100644
index 0000000..6f22d49
--- /dev/null
+++ b/QYZH.InteractiveMagazine.Models/Settings/SnowflakeSettings.cs
@@ -0,0 +1,12 @@
+namespace QYZH.InteractiveMagazine.Models.Settings;
+
+///
+/// 雪花ID配置。
+///
+public class SnowflakeSettings
+{
+ ///
+ /// WorkerId,生产部署时每个写库进程必须唯一。
+ ///
+ public ushort WorkerId { get; set; }
+}
diff --git a/QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs b/QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs
index e785f5d..cd518bb 100644
--- a/QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs
+++ b/QYZH.InteractiveMagazine.PrintWorker/Consumers/AutoDotCodeConsumer.cs
@@ -48,6 +48,8 @@ public class AutoDotCodeConsumer(
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
JournalPagePrintDto? request = null;
+ List reservedDotDetailIds = [];
+ var dotPagesCommitted = false;
try
{
@@ -135,6 +137,21 @@ public class AutoDotCodeConsumer(
}
var dotFileDetailPageName = dotFileDetailList.Select(x => x.PageName).OrderBy(x => x).ToArray();
+ reservedDotDetailIds = dotFileDetailList.Select(x => x.Id).ToList();
+ var reservedRows = await dbContext.Updateable()
+ .SetColumns(x => x.IsUse == true)
+ .SetColumns(x => x.UpdatedAt == DateTime.Now)
+ .Where(x => reservedDotDetailIds.Contains(x.Id) && !x.IsUse)
+ .ExecuteCommandAsync(cancellationToken);
+ if (reservedRows != reservedDotDetailIds.Count)
+ {
+ logger.LogError("点阵页码预占失败,DotId: {DotId}, Need: {Need}, Reserved: {Reserved}", dotId, reservedDotDetailIds.Count, reservedRows);
+ await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
+ reservedDotDetailIds.Clear();
+ await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName);
+ return;
+ }
+
var pageStr = "{" + $"[{dotFileDetailList[0].PageName},{pageNumMax}]" + "}";
var arguments = $"-sMode=Generate -sPDF=\"{uploadFilePath}\" -sLIC=\"{xmlPath}\" -pStart=1 -oPDF=\"{downloadFilePath}\" -dPageAddr=1 -dPrint={dPrint} -dDotSize=40 -dType=0 -dOutFile=0 -dControlPageNum={pageStr}";
var printResult = await ExecutePrintToolAsync(exePath, printToolDirectory, arguments, cancellationToken);
@@ -142,6 +159,8 @@ public class AutoDotCodeConsumer(
if (printResult.Timeout || printResult.ExitCode != 0)
{
logger.LogError("执行 PrintTool.exe 失败,退出码:{ExitCode},错误信息:{ErrorMessage}", printResult.ExitCode, printResult.Error);
+ await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
+ reservedDotDetailIds.Clear();
await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName);
return;
}
@@ -154,6 +173,8 @@ public class AutoDotCodeConsumer(
if (!ValidatePrintOutput(printResult.Output, dotFileDetailPageName, dPrint))
{
+ await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
+ reservedDotDetailIds.Clear();
await TryCallbackCodeFailAsync(request, cancellationToken, dotFileDetailPageName);
return;
}
@@ -189,9 +210,12 @@ public class AutoDotCodeConsumer(
.Where(x => x.Id == dotId)
.ExecuteCommandAsync();
});
+ dotPagesCommitted = true;
}
else
{
+ await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, cancellationToken);
+ reservedDotDetailIds.Clear();
logger.LogError("回调接口修改书籍状态失败,JournalId: {JournalId},接口返回消息:{Message}", statusModel.JournalId, callbackResponse.Message);
}
}
@@ -205,6 +229,11 @@ public class AutoDotCodeConsumer(
logger.LogError(ex, "自动铺码处理失败");
if (request != null)
{
+ if (!dotPagesCommitted && reservedDotDetailIds.Count > 0)
+ {
+ await ReleaseReservedDotPagesAsync(dbContext, reservedDotDetailIds, CancellationToken.None);
+ }
+
await TryCallbackCodeFailAsync(request, CancellationToken.None);
}
}
@@ -329,6 +358,20 @@ public class AutoDotCodeConsumer(
}
}
+ private static async Task ReleaseReservedDotPagesAsync(ISqlSugarClient dbContext, List dotDetailIds, CancellationToken cancellationToken)
+ {
+ if (dotDetailIds.Count == 0)
+ {
+ return;
+ }
+
+ await dbContext.Updateable()
+ .SetColumns(x => x.IsUse == false)
+ .SetColumns(x => x.UpdatedAt == DateTime.Now)
+ .Where(x => dotDetailIds.Contains(x.Id))
+ .ExecuteCommandAsync(cancellationToken);
+ }
+
private static JournalPagePrintDto BuildFailResponse(JournalPagePrintDto request, string[]? pageNo = null)
{
return new JournalPagePrintDto
diff --git a/QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs b/QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs
index 721dbdc..f4d4505 100644
--- a/QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs
+++ b/QYZH.InteractiveMagazine.PrintWorker/Consumers/RabbitMQHostedService.cs
@@ -55,7 +55,7 @@ public class RabbitMQHostedService(IServiceProvider serviceProvider, ILogger SendToDeadLetterQueueAsync(string exchange, string dlqName, string dlqRoutingKey, byte[] body, CancellationToken cancellationToken)
+ private async Task SendToDeadLetterQueueAsync(string exchange, string dlqName, string dlqRoutingKey, string originalQueueName, byte[] body, Exception exception, CancellationToken cancellationToken)
{
try
{
@@ -90,7 +90,14 @@ public class RabbitMQHostedService(IServiceProvider serviceProvider, ILogger
+ {
+ ["x-original-queue"] = originalQueueName,
+ ["x-error-type"] = exception.GetType().FullName,
+ ["x-error-message"] = exception.Message,
+ ["x-failed-at"] = DateTimeOffset.UtcNow.ToString("O")
+ }
};
await channel.BasicPublishAsync(exchange, dlqRoutingKey, false, properties, body, cancellationToken);
logger.LogInformation("消息已发送到死信队列: {DlqName}", dlqName);
diff --git a/QYZH.InteractiveMagazine.PrintWorker/Program.cs b/QYZH.InteractiveMagazine.PrintWorker/Program.cs
index 9f9ed11..0adbe73 100644
--- a/QYZH.InteractiveMagazine.PrintWorker/Program.cs
+++ b/QYZH.InteractiveMagazine.PrintWorker/Program.cs
@@ -1,6 +1,7 @@
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
using QYZH.InteractiveMagazine.Infrastructure.Redis;
using QYZH.InteractiveMagazine.Infrastructure.SDK;
+using QYZH.InteractiveMagazine.Models.Settings;
using QYZH.InteractiveMagazine.PrintWorker.Consumers;
using Serilog;
using SqlSugar;
@@ -34,7 +35,8 @@ Log.Logger = new LoggerConfiguration()
builder.Services.AddSerilog();
builder.Services.AddWindowsService(options => options.ServiceName = serviceName);
-YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = 3 });
+var snowflakeSettings = builder.Configuration.GetSection("SnowflakeSettings").Get() ?? new SnowflakeSettings { WorkerId = 3 };
+YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = snowflakeSettings.WorkerId });
builder.Services.AddSqlSugar(new IocConfig
{
diff --git a/QYZH.InteractiveMagazine.PrintWorker/appsettings.json b/QYZH.InteractiveMagazine.PrintWorker/appsettings.json
index 028a828..bd31cac 100644
--- a/QYZH.InteractiveMagazine.PrintWorker/appsettings.json
+++ b/QYZH.InteractiveMagazine.PrintWorker/appsettings.json
@@ -2,6 +2,9 @@
"ConnectionStrings": {
"DefaultConnection": "server=192.168.20.150;port=13306;database=InteractiveMagazine;user=user;password=n68792bu!y99r905;charset=utf8mb4;"
},
+ "SnowflakeSettings": {
+ "WorkerId": 3
+ },
"RedisSettings": {
"ConnectionString": "192.168.20.150:16379,defaultDatabase=5",
"Sentinels": [],
@@ -14,6 +17,10 @@
"Password": "@ss%&*otz%d*pq2S",
"VirtualHost": "InteractiveMagazine"
},
+ "RabbitMQRetrySettings": {
+ "MaxRetryCount": 3,
+ "RetryDelayMilliseconds": 30000
+ },
"Serilog": {
"MinimumLevel": {
"Default": "Information",
diff --git a/QYZH.InteractiveMagazine.Repository/BaseRepository.cs b/QYZH.InteractiveMagazine.Repository/BaseRepository.cs
index d1fb61e..86b38b6 100644
--- a/QYZH.InteractiveMagazine.Repository/BaseRepository.cs
+++ b/QYZH.InteractiveMagazine.Repository/BaseRepository.cs
@@ -253,9 +253,9 @@ namespace QYZH.InteractiveMagazine.Repository
var total = 0;
page.PageSize = parm.PageSize;
page.PageIndex = parm.PageIndex;
- if (string.IsNullOrEmpty(parm.Sort))
+ if (!string.IsNullOrWhiteSpace(parm.Sort))
{
- source.OrderByPropertyName(parm.Sort, parm.SortType.Contains("desc") ? OrderByType.Desc : OrderByType.Asc);
+ source.OrderByPropertyName(parm.Sort, parm.SortType?.Contains("desc", StringComparison.OrdinalIgnoreCase) == true ? OrderByType.Desc : OrderByType.Asc);
}
page.Result = source
//.OrderByIF(parm.Sort.IsNotEmpty(), $"{parm.Sort.ToSqlFilter()} {(!string.IsNullOrWhiteSpace(parm.SortType) && parm.SortType.Contains("desc") ? "desc" : "asc")}")
@@ -278,9 +278,9 @@ namespace QYZH.InteractiveMagazine.Repository
var total = 0;
page.PageSize = parm.PageSize;
page.PageIndex = parm.PageIndex;
- if (string.IsNullOrEmpty(parm.Sort))
+ if (!string.IsNullOrWhiteSpace(parm.Sort))
{
- source.OrderByPropertyName(parm.Sort, parm.SortType.Contains("desc") ? OrderByType.Desc : OrderByType.Asc);
+ source.OrderByPropertyName(parm.Sort, parm.SortType?.Contains("desc", StringComparison.OrdinalIgnoreCase) == true ? OrderByType.Desc : OrderByType.Asc);
}
var result = source
//.OrderByIF(parm.Sort.IsNotEmpty(), $"{parm.Sort.ToSqlFilter()} {(!string.IsNullOrWhiteSpace(parm.SortType) && parm.SortType.Contains("desc") ? "desc" : "asc")}")
diff --git a/QYZH.InteractiveMagazine.Service/CheckInService.cs b/QYZH.InteractiveMagazine.Service/CheckInService.cs
index f967216..34b854f 100644
--- a/QYZH.InteractiveMagazine.Service/CheckInService.cs
+++ b/QYZH.InteractiveMagazine.Service/CheckInService.cs
@@ -74,6 +74,14 @@ public class CheckInService(
await checkInRecordRepository.UseTranAsync(async () =>
{
+ var duplicate = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= today && r.CheckInDate < today.AddDays(1))
+ .AnyAsync();
+ if (duplicate)
+ {
+ throw new BusinessException("今日已签到,请明天再来", ResultCode.CONFLICT);
+ }
+
// 6a. 创建签到记录
var checkInRecord = new CheckInRecord
{
@@ -94,10 +102,16 @@ public class CheckInService(
var newGrowthBalance = user.GrowthPoints + growthReward;
await checkInRecordRepository.Context.Updateable()
- .SetColumns(u => u.GrowthPoints == newGrowthBalance)
+ .SetColumns(u => u.GrowthPoints == u.GrowthPoints + growthReward)
+ .SetColumns(u => u.UpdatedAt == DateTime.Now)
.Where(u => u.Id == userId && !u.IsDeleted)
.ExecuteCommandAsync();
+ newGrowthBalance = await checkInRecordRepository.Context.Queryable()
+ .Where(u => u.Id == userId && !u.IsDeleted)
+ .Select(u => u.GrowthPoints)
+ .FirstAsync();
+
// 6c. 通过积分服务增加积分
var pointsResult = await pointsService.AddPointsInTranAsync(new AddPointsInput
{
@@ -271,6 +285,14 @@ public class CheckInService(
await checkInRecordRepository.UseTranAsync(async () =>
{
+ var duplicate = await checkInRecordRepository.Context.Queryable()
+ .Where(r => r.UserId == userId && !r.IsDeleted && r.CheckInDate >= targetDate && r.CheckInDate < targetDate.AddDays(1))
+ .AnyAsync();
+ if (duplicate)
+ {
+ throw new BusinessException($"{targetDate:yyyy-MM-dd} 已签到,无需补签", ResultCode.CONFLICT);
+ }
+
// 创建补签记录
var checkInRecord = new CheckInRecord
{
@@ -290,14 +312,17 @@ public class CheckInService(
var recordId = await checkInRecordRepository.Insertable(checkInRecord).ExecuteReturnIdentityAsync();
checkInRecord.Id = recordId;
- // 更新用户成长值
- var newGrowthBalance = user.GrowthPoints + growthReward;
-
await checkInRecordRepository.Context.Updateable()
- .SetColumns(u => u.GrowthPoints == newGrowthBalance)
+ .SetColumns(u => u.GrowthPoints == u.GrowthPoints + growthReward)
+ .SetColumns(u => u.UpdatedAt == DateTime.Now)
.Where(u => u.Id == userId && !u.IsDeleted)
.ExecuteCommandAsync();
+ var newGrowthBalance = await checkInRecordRepository.Context.Queryable()
+ .Where(u => u.Id == userId && !u.IsDeleted)
+ .Select(u => u.GrowthPoints)
+ .FirstAsync();
+
// 通过积分服务增加积分
var pointsResult = await pointsService.AddPointsInTranAsync(new AddPointsInput
{
@@ -311,19 +336,20 @@ public class CheckInService(
// 扣减补签卡
if (makeUpCard.Quantity <= 1)
{
+ var cardAffectedRows = await checkInRecordRepository.Context.Updateable()
+ .SetColumns(b => b.Quantity == b.Quantity - 1)
+ .SetColumns(b => b.UpdatedAt == DateTime.Now)
+ .Where(b => b.Id == makeUpCard.Id && b.UserId == userId && b.Quantity > 0 && b.Status == (int)UserBagStatusEnum.Available && !b.IsDeleted)
+ .ExecuteCommandAsync();
+ if (cardAffectedRows <= 0)
+ {
+ throw new BusinessException("补签卡不足,无法补签", ResultCode.CONFLICT);
+ }
+
await checkInRecordRepository.Context.Updateable()
.SetColumns(b => b.Status == (int)UserBagStatusEnum.Expired)
- .SetColumns(b => b.Quantity == 0)
.SetColumns(b => b.UpdatedAt == DateTime.Now)
- .Where(b => b.Id == makeUpCard.Id)
- .ExecuteCommandAsync();
- }
- else
- {
- await checkInRecordRepository.Context.Updateable()
- .SetColumns(b => b.Quantity == makeUpCard.Quantity - 1)
- .SetColumns(b => b.UpdatedAt == DateTime.Now)
- .Where(b => b.Id == makeUpCard.Id)
+ .Where(b => b.Id == makeUpCard.Id && b.Quantity <= 0 && !b.IsDeleted)
.ExecuteCommandAsync();
}
diff --git a/QYZH.InteractiveMagazine.Service/JournalPageService.cs b/QYZH.InteractiveMagazine.Service/JournalPageService.cs
index cbcaf57..6111da3 100644
--- a/QYZH.InteractiveMagazine.Service/JournalPageService.cs
+++ b/QYZH.InteractiveMagazine.Service/JournalPageService.cs
@@ -11,6 +11,7 @@ using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Dto.Journal;
+using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
@@ -24,7 +25,7 @@ public class JournalPageService(BaseRepository JournalRepository,
OssService ossService,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
- IRabbitMQService rabbitMqService,
+ IMessagePublishService messagePublishService,
ILogger logger,
BaseRepository JournalPageRepository,
BaseRepository JournalPageTaskRepository,
@@ -135,18 +136,29 @@ public class JournalPageService(BaseRepository JournalRepository,
var isPdfExists = ossService.DoesObjectExist(uploadPdfKey);
BusinessException.ThrowIf(!isPdfExists, "获取期刊上传的PDF文件失败,请检查PDF文件是否上传成功", ResultCode.GLOBAL_ERROR);
- journalEntity.Status = (int)JournalStatusEnum.Codeing;
-
- await JournalRepository.Updateable(journalEntity).UpdateColumns(x => new { x.Status, x.UpdatedAt }).ExecuteCommandAsync();
-
var data = new JournalPagePrintDto
{
JournalId = JournalId,
JournalPdfUrl = uploadPdfUrl,
PageNum = [.. journalPageList.Select(x => x.PageNum)]
};
- var msRes = await rabbitMqService.SendAsync(new RabbitMQSendParam { Exchange = "ex.journal", Queue = "mq.journal.dotcode.auto", RoutingKey = "rk.journal.dotcode.auto", Data = data });//发生消息
- return msRes;
+
+ return await UseTranAsync(async () =>
+ {
+ journalEntity.Status = (int)JournalStatusEnum.Codeing;
+ journalEntity.UpdatedAt = DateTime.Now;
+ await JournalRepository.Updateable(journalEntity).UpdateColumns(x => new { x.Status, x.UpdatedAt }).ExecuteCommandAsync();
+ await messagePublishService.PublishAsync(new MessagePublishInput
+ {
+ Exchange = "ex.journal",
+ Queue = "mq.journal.dotcode.auto",
+ RoutingKey = "rk.journal.dotcode.auto",
+ Data = data,
+ BusinessType = "JournalDotCode",
+ BusinessId = JournalId
+ });
+ return true;
+ });
}
///
/// 回调接口-自动铺码, 书页铺码后回调接口,更新书页的点阵码
diff --git a/QYZH.InteractiveMagazine.Service/JournalService.cs b/QYZH.InteractiveMagazine.Service/JournalService.cs
index 32fbea3..54a5a16 100644
--- a/QYZH.InteractiveMagazine.Service/JournalService.cs
+++ b/QYZH.InteractiveMagazine.Service/JournalService.cs
@@ -9,10 +9,12 @@ using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Dto.DotMatrix;
using QYZH.InteractiveMagazine.Models.Dto.Journal;
+using QYZH.InteractiveMagazine.Models.Dto.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
using SqlSugar;
+using System.Text.Json;
using Yitter.IdGenerator;
namespace QYZH.InteractiveMagazine.Service;
@@ -24,7 +26,7 @@ public class JournalService(BaseRepository JournalPageRepository,
BaseRepository dotFileRepository,
BaseRepository dotFileDetailRepository,
OssService ossService,
- IRabbitMQService rabbitMqService,
+ IMessagePublishService messagePublishService,
ILogger logger) : BaseRepository, IJournalService
{
private const string JournalExchange = "ex.journal";
@@ -375,23 +377,27 @@ public class JournalService(BaseRepository JournalPageRepository,
x => x.Key,
x => string.Join(',', x.OrderBy(q => ParseTaskNo(q.No)).Select(q => q.Id)));
- var bookMessageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam
+ var publishMessages = new List>
{
- Exchange = JournalExchange,
- Queue = PublishBookQueue,
- RoutingKey = PublishBookRoutingKey,
- Data = new JournalPublishBookMessage
+ new()
{
- BookId = id,
- StartTime = book.StartTime.Value,
- EndTime = book.EndTime.Value
+ Exchange = JournalExchange,
+ Queue = PublishBookQueue,
+ RoutingKey = PublishBookRoutingKey,
+ Data = new JournalPublishBookMessage
+ {
+ BookId = id,
+ StartTime = book.StartTime.Value,
+ EndTime = book.EndTime.Value
+ },
+ BusinessType = "JournalPublish",
+ BusinessId = id
}
- });
- BusinessException.ThrowIf(!bookMessageSent, "发布书籍消息发送失败", ResultCode.GLOBAL_ERROR);
+ };
foreach (var page in pages)
{
- var pageMessageSent = await rabbitMqService.SendAsync(new RabbitMQSendParam
+ publishMessages.Add(new MessagePublishInput