feat: 新增工作服务项目并调整部分接口路由

1. 新增QYZH.InteractiveMagazine.WorkService工作服务项目,包含队列消费者框架、Hangfire定时任务支持
2. 修复PetService中未定义FeedPetOutput变量的问题
3. 调整JournalController的路由前缀从api/icr改为api
4. 将工作服务项目添加到解决方案中
This commit is contained in:
glz
2026-06-11 18:01:07 +08:00
parent e493c85d08
commit 4142b621c3
12 changed files with 433 additions and 2 deletions

View File

@ -0,0 +1,56 @@
namespace QYZH.InteractiveMagazine.Models.Settings;
/// <summary>
/// Hangfire 定时任务配置集合(对应 appsettings.json 中的 "HangfireJobs" 节点)
/// </summary>
public class HangfireJobSettings
{
/// <summary>
/// 定时任务列表,每一项对应一个 Hangfire RecurringJob
/// </summary>
public List<HangfireJobConfig> Jobs { get; set; } = new();
}
/// <summary>
/// 单个定时任务配置
/// </summary>
public class HangfireJobConfig
{
/// <summary>
/// 任务名称Hangfire Dashboard 中展示的唯一标识,建议英文短横线命名,如 "sample-job"
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Job 类的完整类型名称(含命名空间,如 "QYZH.InteractiveMagazine.WorkService.Jobs.SampleJob"
/// </summary>
public string JobType { get; set; } = "";
/// <summary>
/// 要调用的方法名称(默认 "ExecuteAsync",方法必须为 public 无参 Task
/// </summary>
public string MethodName { get; set; } = "ExecuteAsync";
/// <summary>
/// Cron 表达式,控制执行频率。常用示例:
/// "* * * * *" 每分钟
/// "*/5 * * * *" 每5分钟
/// "0 * * * *" 每小时整点
/// "0 0 * * *" 每天午夜
/// "0 9 * * *" 每天上午9点
/// "0 9 * * 1" 每周一上午9点
/// "0 0 1 * *" 每月1号午夜
/// 格式:分 时 日 月 星期5位不支持秒和年
/// </summary>
public string Cron { get; set; } = "";
/// <summary>
/// 是否启用此任务(设为 false 将从 Hangfire 中移除该任务)
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 备注说明(仅供阅读理解,不参与运行逻辑)
/// </summary>
public string? Description { get; set; }
}

View File

@ -252,7 +252,7 @@ public class PetService(
logger.LogWarning("喂养失败宠物未激活PetId: {PetId}, Status: {Status}", input.PetId, pet.Status); logger.LogWarning("喂养失败宠物未激活PetId: {PetId}, Status: {Status}", input.PetId, pet.Status);
throw new BusinessException("宠物未激活,无法喂养", 400); throw new BusinessException("宠物未激活,无法喂养", 400);
} }
FeedPetOutput result = new FeedPetOutput ();
// 事务保证一致性 // 事务保证一致性
await UseTranAsync(async () => await UseTranAsync(async () =>
{ {

View File

@ -22,7 +22,7 @@ namespace QYZH.InteractiveMagazine.WebApi.Controllers
/// 期刊管理 /// 期刊管理
/// </summary> /// </summary>
[ApiExplorerSettings(GroupName = nameof(ApiVersionEnum.Platform))] [ApiExplorerSettings(GroupName = nameof(ApiVersionEnum.Platform))]
[Route("api/icr")] [Route("api")]
public class JournalController( public class JournalController(
OssService ossService, OssService ossService,
IJournalCatalogService JournalCatalogService, IJournalCatalogService JournalCatalogService,

View File

@ -0,0 +1,22 @@
namespace QYZH.InteractiveMagazine.WorkService.Consumers;
/// <summary>
/// 队列消费者接口
/// </summary>
public interface IQueueConsumer
{
/// <summary>
/// 监听的队列名称
/// </summary>
string QueueName { get; }
/// <summary>
/// 处理消息
/// </summary>
Task HandleAsync(byte[] message);
/// <summary>
/// 处理消费异常
/// </summary>
Task OnErrorAsync(byte[] message, Exception exception);
}

View File

@ -0,0 +1,35 @@
using System.Text;
namespace QYZH.InteractiveMagazine.WorkService.Consumers;
/// <summary>
/// 期刊任务接收消费者(示例)
/// </summary>
public class JournalTaskReceiveConsumer : IQueueConsumer
{
private readonly ILogger<JournalTaskReceiveConsumer> _logger;
public JournalTaskReceiveConsumer(ILogger<JournalTaskReceiveConsumer> logger)
{
_logger = logger;
}
public string QueueName => "mq.Journal.task.receive";
public async Task HandleAsync(byte[] message)
{
var body = Encoding.UTF8.GetString(message);
_logger.LogInformation("收到期刊任务消息: {Message}", body);
// TODO: 在此编写具体的消息处理逻辑
await Task.CompletedTask;
}
public Task OnErrorAsync(byte[] message, Exception exception)
{
var body = Encoding.UTF8.GetString(message);
_logger.LogError(exception, "处理期刊任务消息失败: {Message}", body);
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,82 @@
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
namespace QYZH.InteractiveMagazine.WorkService.Consumers;
/// <summary>
/// RabbitMQ 消费者后台服务,自动发现并启动所有已注册的 IQueueConsumer
/// </summary>
public class RabbitMQHostedService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<RabbitMQHostedService> _logger;
public RabbitMQHostedService(IServiceProvider serviceProvider, ILogger<RabbitMQHostedService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var scope = _serviceProvider.CreateScope();
var consumers = scope.ServiceProvider.GetServices<IQueueConsumer>().ToList();
if (consumers.Count == 0)
{
_logger.LogWarning("未注册任何队列消费者");
return;
}
_logger.LogInformation("发现 {Count} 个队列消费者,开始启动...", consumers.Count);
var tasks = consumers.Select(c => StartConsumerAsync(c.QueueName, stoppingToken)).ToList();
await Task.WhenAll(tasks);
}
private async Task StartConsumerAsync(string queueName, CancellationToken stoppingToken)
{
_logger.LogInformation("正在启动消费者: {QueueName}", queueName);
try
{
var rabbitMQService = _serviceProvider.GetRequiredService<IRabbitMQService>();
await rabbitMQService.ReceiveAsync(queueName, async (channel, ea) =>
{
// 每条消息创建独立 scope确保消费者内可注入 Scoped 服务
using var messageScope = _serviceProvider.CreateScope();
var consumer = messageScope.ServiceProvider
.GetServices<IQueueConsumer>()
.First(c => c.QueueName == queueName);
try
{
await consumer.HandleAsync(ea.Body.ToArray());
await channel.BasicAckAsync(ea.DeliveryTag, false, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "消费者 {QueueName} 处理消息异常", queueName);
await channel.BasicNackAsync(ea.DeliveryTag, false, true, stoppingToken);
try
{
await consumer.OnErrorAsync(ea.Body.ToArray(), ex);
}
catch (Exception errorEx)
{
_logger.LogError(errorEx, "消费者 {QueueName} OnErrorAsync 执行异常", queueName);
}
}
}, stoppingToken);
}
catch (OperationCanceledException)
{
_logger.LogInformation("消费者 {QueueName} 已停止", queueName);
}
catch (Exception ex)
{
_logger.LogError(ex, "消费者 {QueueName} 启动失败", queueName);
}
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.Extensions.Logging;
namespace QYZH.InteractiveMagazine.WorkService.Jobs;
/// <summary>
/// 示例定时任务
/// </summary>
public class SampleJob
{
private readonly ILogger<SampleJob> _logger;
public SampleJob(ILogger<SampleJob> logger)
{
_logger = logger;
}
/// <summary>
/// 执行定时任务由Hangfire调度
/// </summary>
public async Task ExecuteAsync()
{
_logger.LogInformation("SampleJob 开始执行 {Time}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
// TODO: 在此编写具体的定时任务逻辑
await Task.CompletedTask;
_logger.LogInformation("SampleJob 执行完毕");
}
}

View File

@ -0,0 +1,120 @@
using Hangfire;
using Hangfire.Dashboard;
using Hangfire.MemoryStorage;
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
using QYZH.InteractiveMagazine.Models.Settings;
using QYZH.InteractiveMagazine.WorkService.Consumers;
using QYZH.InteractiveMagazine.WorkService.Jobs;
using Serilog;
using SqlSugar;
using SqlSugar.IOC;
using System.Linq.Expressions;
using System.Reflection;
using Yitter.IdGenerator;
var builder = WebApplication.CreateBuilder(args);
// 加载配置
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true);
// 配置Serilog
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.CreateLogger();
builder.Services.AddSerilog();
// 初始化雪花ID生成器
YitIdHelper.SetIdGenerator(new IdGeneratorOptions { WorkerId = 2 });
// 初始化MySQLSqlSugar
builder.Services.AddSqlSugar(new IocConfig
{
ConfigId = 0,
DbType = IocDbType.MySql,
ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection"),
IsAutoCloseConnection = true
});
SugarIocServices.ConfigurationSugar(db =>
{
db.Aop.OnLogExecuting = (sql, pars) =>
{
Log.Information("[SQL] {Sql}", UtilMethods.GetSqlString((DbType)IocDbType.MySql, sql, pars));
};
db.Aop.OnError = ex =>
{
Log.Error(ex, "[SQL Error] {Message}", ex.Message);
};
});
// 配置Hangfire内存存储后续可切换Redis
builder.Services.AddHangfire(config => config
.UseMemoryStorage()
.UseSerializerSettings(new Newtonsoft.Json.JsonSerializerSettings
{
TypeNameHandling = Newtonsoft.Json.TypeNameHandling.All
}));
builder.Services.AddHangfireServer();
// 配置RabbitMQ
builder.Services.AddRabbitMQ(builder.Configuration);
// 注册队列消费者(新增消费者只需实现 IQueueConsumer 并在此注册)
builder.Services.AddScoped<IQueueConsumer, JournalTaskReceiveConsumer>();
// 注册消费者后台服务
builder.Services.AddHostedService<RabbitMQHostedService>();
// 从配置文件读取定时任务列表
var jobSettings = builder.Configuration.GetSection("HangfireJobs").Get<HangfireJobSettings>();
var app = builder.Build();
// 配置Hangfire Dashboard仅本机访问
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
Authorization = new[] { new LocalRequestsOnlyAuthorizationFilter() }
});
// 根据配置动态注册定时任务
if (jobSettings?.Jobs != null)
{
foreach (var job in jobSettings.Jobs)
{
var jobType = Type.GetType(job.JobType);
if (jobType == null)
{
Log.Warning("定时任务 [{Name}] 类型未找到: {JobType},跳过注册", job.Name, job.JobType);
continue;
}
if (!job.Enabled)
{
// 配置为关闭的任务,从 Hangfire 中移除
RecurringJob.RemoveIfExists(job.Name);
Log.Information("定时任务 [{Name}] 已禁用,已移除", job.Name);
continue;
}
// 构造表达式job => job.MethodName()
var method = jobType.GetMethod(job.MethodName);
if (method == null)
{
Log.Warning("定时任务 [{Name}] 方法未找到: {MethodName},跳过注册", job.Name, job.MethodName);
continue;
}
var param = Expression.Parameter(jobType, "job");
var call = Expression.Call(param, method);
var lambda = Expression.Lambda<Action>(call, param);
RecurringJob.AddOrUpdate(job.Name, lambda, job.Cron);
Log.Information("定时任务 [{Name}] 已注册Cron: {Cron}", job.Name, job.Cron);
}
}
Log.Information("WorkService 已启动Hangfire Dashboard: /hangfire");
app.Run();

View File

@ -0,0 +1,12 @@
{
"profiles": {
"QYZH.InteractiveMagazine.WorkService": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53991;http://localhost:53992"
}
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\QYZH.InteractiveMagazine.Common\QYZH.InteractiveMagazine.Common.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Models\QYZH.InteractiveMagazine.Models.csproj" />
<ProjectReference Include="..\QYZH.InteractiveMagazine.Infrastructure\QYZH.InteractiveMagazine.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.8.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="SqlSugar.IOC" Version="2.0.1" />
<PackageReference Include="SqlSugarCoreNoDrive" Version="5.1.4.213" />
<PackageReference Include="MySqlConnector" Version="2.5.0" />
<PackageReference Include="Yitter.IdGenerator" Version="1.0.14" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,48 @@
{
"ConnectionStrings": {
"DefaultConnection": "server=192.168.20.150;port=13306;database=InteractiveMagazine;user=user;password=n68792bu!y99r905;charset=utf8mb4;"
},
"RabbitMq": {
"HostName": "192.168.20.150",
"Port": 5672,
"UserName": "smartschool",
"Password": "@ss%&*otz%d*pq2S",
"VirtualHost": "InteractiveMagazine"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning",
"Hangfire": "Information"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "File",
"Args": {
"path": "logs/worker-log-.txt",
"rollingInterval": "Day"
}
}
]
},
"HangfireJobs": {
"_说明": "定时任务配置。新增任务只需在 Jobs 数组中追加一项。Cron格式分 时 日 月 星期5位。常用* * * * * 每分钟 | */5 * * * * 每5分钟 | 0 * * * * 每小时 | 0 9 * * * 每天9点 | 0 0 * * 1 每周一午夜",
"Jobs": [
{
"Name": "sample-job",
"JobType": "QYZH.InteractiveMagazine.WorkService.Jobs.SampleJob",
"MethodName": "ExecuteAsync",
"Cron": "* * * * *",
"Enabled": false,
"Description": "示例任务(默认关闭,仅用于验证框架运行)"
}
]
},
"AllowedHosts": "*"
}

View File

@ -6,4 +6,5 @@
<Project Path="QYZH.InteractiveMagazine.Repository/QYZH.InteractiveMagazine.Repository.csproj" /> <Project Path="QYZH.InteractiveMagazine.Repository/QYZH.InteractiveMagazine.Repository.csproj" />
<Project Path="QYZH.InteractiveMagazine.Service/QYZH.InteractiveMagazine.Service.csproj" /> <Project Path="QYZH.InteractiveMagazine.Service/QYZH.InteractiveMagazine.Service.csproj" />
<Project Path="QYZH.InteractiveMagazine.WebApi/QYZH.InteractiveMagazine.WebApi.csproj" /> <Project Path="QYZH.InteractiveMagazine.WebApi/QYZH.InteractiveMagazine.WebApi.csproj" />
<Project Path="QYZH.InteractiveMagazine.WorkService/QYZH.InteractiveMagazine.WorkService.csproj" />
</Solution> </Solution>