前言
前面几篇已经把 HQServer 的 JWT、Quartz、RabbitMQ 等基础功能接进来了。到这里,如果只看单个功能,可能觉得都不复杂;真正开始写业务时,问题往往出在它们怎么串起来。
所以这篇不再单独讲某一个组件,而是拿项目里现成的待办 Demo 走一遍。登录拿到 Token 以后,请求是怎样进入 Controller 的,权限在哪里判断,待办怎样写入数据库,缓存什么时候清理,RabbitMQ 的消息又从哪里发出去,都会结合代码说明。
Demo 代码在 HQ.Application/Application/Demo 和 HQ.Service/Service/DemoTodoService.cs。下面的代码以当前项目源码为准,删掉了少量与本段无关的内容,方便阅读。
一、先看这个 Demo 到底做了什么
它是一个很小的待办接口,提供新增、查询、修改和删除。看起来业务很普通,但一次新增操作会经过 JWT 权限、参数校验、SqlSugar 仓储、UnitOfWork、缓存失效和 RabbitMQ 事件发布。除此之外,项目还放了文件上传、SignalR 广播和 Quartz 定时任务几个独立示例。
目录大致如下:
HQ.Application/Application/Demo
├── DemoAuthController.cs
├── DemoTodoController.cs
├── DemoFileController.cs
├── DemoMessageController.cs
└── DemoRealtimeController.cs
HQ.Service/Interface/IDemoTodoService.cs
HQ.Service/Service/DemoTodoService.cs
HQ.Entity/Model/DemoTodo.cs
HQ.Entity/DTO/DemoTodoDto.cs
HQ.Application/Extensions/DemoDatabaseExtensions.cs
HQ.Application/DemoQuartzJob.cs
Controller 只处理 HTTP,Service 负责业务过程,Entity 负责数据结构。缓存、事务和消息虽然都在一次业务里出现,但没有直接写进 Controller,这一点比接口本身更值得参考。
二、启动时把 Demo 表建出来
待办实体没有什么复杂字段:
using HQ.Common.ORM.SQLSugar;
using SqlSugar;
namespace HQ.Entity.Model;
[SugarTable("DemoTodos")]
public sealed class DemoTodo : AuditedEntity<string>
{
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsCompleted { get; set; }
}
继承 AuditedEntity<string> 后,主键和审计字段由框架基类提供,Demo 只声明自己的业务字段。首次启动时调用 CodeFirst 创建表:
public static Task EnsureDemoDatabaseAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
db.CodeFirst.InitTables<DemoTodo>();
return Task.CompletedTask;
}
这个方法在数据库预热成功后调用:
await app.WarmupDatabaseAsync();
await app.EnsureDemoDatabaseAsync();
这样做是为了让启动失败更容易判断。连不上数据库时直接在预热阶段失败,不会等到第一次请求才发现问题。CodeFirst InitTables 适合这个可运行 Demo,正式项目的生产库还是建议使用迁移脚本或版本化数据库变更。
三、登录接口只负责演示 JWT
Demo 没有接入用户表,登录接口只要收到非空用户名,就生成一个演示 Token:
[ApiController]
[Route("api/demo/auth")]
public sealed class DemoAuthController(
IJwtTokenService tokenService,
ICurrentUser currentUser) : ControllerBase
{
[HttpPost("token")]
public ActionResult<ApiResult<object>> CreateToken(
[FromBody] DemoLoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.UserName))
throw new BusinessException("用户名不能为空");
var token = tokenService.GenerateAccessToken(new LoginUserDto
{
UserId = request.UserName.Trim().ToLowerInvariant(),
UserName = request.UserName.Trim(),
NickName = "HQServer Demo",
Roles = ["DemoAdmin"],
Permissions = ["demo.read", "demo.write"]
});
return Ok(ApiResult<object>.Ok(new
{
accessToken = token,
tokenType = "Bearer",
expiresInMinutes = 120
}));
}
}
请求:
POST /api/demo/auth/token
Content-Type: application/json
{
"userName": "demo-admin"
}
拿到 accessToken 后,在 Swagger 的 Authorize 中填写 Bearer 你的Token,后面的待办接口才能通过权限检查。
这个接口故意没有密码校验,是为了让 Demo 克隆下来就能调试。生产登录不能照搬,至少要接用户表、校验密码哈希、检查用户状态,再考虑刷新 Token 和登录风控。
四、Controller 不直接写业务
待办 Controller 的代码很短:
[ApiController]
[Route("api/demo/todos")]
public sealed class DemoTodoController(
IDemoTodoService todoService) : ControllerBase
{
[HttpGet]
[Permission("demo.read")]
public async Task<ActionResult<ApiResult<IReadOnlyList<DemoTodoDto>>>> GetList(
CancellationToken cancellationToken)
=> Ok(ApiResult<IReadOnlyList<DemoTodoDto>>.Ok(
await todoService.GetListAsync(cancellationToken)));
[HttpPost]
[Permission("demo.write")]
public async Task<ActionResult<ApiResult<DemoTodoDto>>> Create(
CreateDemoTodoRequest request,
CancellationToken cancellationToken)
=> Ok(ApiResult<DemoTodoDto>.Ok(
await todoService.CreateAsync(request, cancellationToken),
"新增成功"));
[HttpPut("{id}")]
[Permission("demo.write")]
public async Task<ActionResult<ApiResult<DemoTodoDto>>> Update(
string id,
UpdateDemoTodoRequest request,
CancellationToken cancellationToken)
=> Ok(ApiResult<DemoTodoDto>.Ok(
await todoService.UpdateAsync(id, request, cancellationToken),
"更新成功"));
}
[Permission("demo.read")] 和 [Permission("demo.write")] 就是这个 Demo 的权限入口。Token 里没有对应权限时,请求在进入方法前就会被拦截。Controller 没有自己解析 Claim,也没有自己判断数据库数据,这样接口层不会越来越胖。
单条查询和删除也一样,只是多了一步“不存在”的判断:
var item = await todoService.GetByIdAsync(id, cancellationToken);
if (item is null)
throw new BusinessException("待办不存在", ApiResultCodes.NotFound);
return Ok(ApiResult<DemoTodoDto>.Ok(item));
五、先看查询:缓存只放在真正需要的地方
Service 中的列表查询先读缓存,缓存没有命中再查数据库:
private const string ListCacheKey = "demo:todos";
public async Task<IReadOnlyList<DemoTodoDto>> GetListAsync(
CancellationToken cancellationToken = default)
{
var cached = await cache.GetAsync<List<DemoTodoDto>>(
ListCacheKey, cancellationToken);
if (cached is not null)
return cached;
var items = await repository.GetListAsync();
var result = items
.OrderByDescending(x => x.CreatedAt)
.Select(ToDto)
.ToList();
await cache.SetAsync(
ListCacheKey,
result,
TimeSpan.FromMinutes(1),
cancellationToken);
return result;
}
这里没有给每一条待办单独做缓存。Demo 只有一个列表,维护一个列表缓存键就够了。缓存有效期只有一分钟,写操作成功后还会主动删除它,所以不会长时间显示旧列表。
单条查询则直接走仓储:
public async Task<DemoTodoDto?> GetByIdAsync(
string id,
CancellationToken cancellationToken = default)
{
var item = await repository.GetByIdAsync(id);
return item is null ? null : ToDto(item);
}
什么时候做单项缓存,要看访问量和数据变化频率。缓存不是加得越多越好,键越多,失效规则也越容易出问题。
六、创建待办:代码真正串起来的地方
新增方法是这个 Demo 的核心:
public async Task<DemoTodoDto> CreateAsync(
CreateDemoTodoRequest request,
CancellationToken cancellationToken = default)
{
ValidateTitle(request.Title);
var entity = new DemoTodo
{
Title = request.Title.Trim(),
Description = Normalize(request.Description)
};
await unitOfWork.ExecuteAsync(async ct =>
{
if (!await repository.AddAsync(entity))
throw new BusinessException("新增待办失败");
await PublishAsync("todo.created", entity, ct);
}, cancellationToken: cancellationToken);
await cache.RemoveAsync(ListCacheKey, cancellationToken);
return ToDto(entity);
}
标题校验放在事务外,因为这类校验失败根本不需要打开事务。真正写库和发布事件放到 IHQUnitOfWork.ExecuteAsync 中,数据库写入失败时直接抛业务异常。
这里有一个容易误解的地方:数据库事务和 RabbitMQ 不是同一个事务。把发布代码写在 UnitOfWork 委托里,并不能让 RabbitMQ 自动获得数据库事务的回滚能力。如果业务要求“数据库成功,消息一定最终送达”,生产环境还需要 Outbox。Demo 只是把消息发布的位置展示出来,没有假装它解决了分布式一致性。
缓存删除放在 UnitOfWork 成功之后。如果数据库回滚了,列表缓存就不应该被提前清理。
七、更新和删除为什么照着同一个套路写
更新先取出实体,再修改字段:
var entity = await repository.GetByIdAsync(id)
?? throw new BusinessException("待办不存在", ApiResultCodes.NotFound);
ValidateTitle(request.Title);
entity.Title = request.Title.Trim();
entity.Description = Normalize(request.Description);
entity.IsCompleted = request.IsCompleted;
entity.UpdatedAt = DateTime.UtcNow;
await unitOfWork.ExecuteAsync(async ct =>
{
if (!await repository.UpdateAsync(entity))
throw new BusinessException("更新待办失败");
await PublishAsync("todo.updated", entity, ct);
}, cancellationToken: cancellationToken);
await cache.RemoveAsync(ListCacheKey, cancellationToken);
删除也是一样的结构,只是仓储操作换成 DeleteAsync,事件换成 todo.deleted。当前 Demo 使用物理删除,如果实际业务需要保留操作记录,可以改为软删除,并在查询层统一过滤。
标题规则单独放在方法里,创建和更新共用:
private static void ValidateTitle(string? title)
{
if (string.IsNullOrWhiteSpace(title))
throw new BusinessException("标题不能为空");
if (title.Trim().Length > 100)
throw new BusinessException("标题不能超过 100 个字符");
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
这种校验不复杂,但集中起来以后,接口之间不会出现一套允许空标题、另一套不允许空标题的情况。
八、RabbitMQ 事件发布
待办服务没有直接操作 RabbitMQ 的 Connection 或 Channel,而是调用框架封装的发布器:
private Task PublishAsync(
string eventName,
DemoTodo todo,
CancellationToken cancellationToken)
=> publisher.PublishAsync(
new DemoOperationMessage
{
EventName = eventName,
TodoId = todo.Id,
Title = todo.Title
},
new RabbitMQPublishOptions
{
RoutingKey = eventName
},
cancellationToken);
消息只带事件名、待办 ID 和标题快照,不直接把 ORM 实体序列化出去。以后消费者增加审计日志、搜索索引或通知功能时,拿到这份消息就能处理。
如果本地没有 RabbitMQ,可以配置:
{
"RabbitMQ": {
"Enabled": false
}
}
框架发布器会跳过实际发布,待办接口仍可以调试。这里是否允许静默跳过,要根据业务决定;订单支付这类关键事件,通常不能简单忽略。
项目还提供了一个直接发消息的接口:
[HttpPost]
public async Task<ActionResult<ApiResult>> Publish(
[FromBody] DemoOperationMessage message,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(message.EventName))
throw new BusinessException("事件名称不能为空");
await publisher.PublishAsync(
message,
new RabbitMQPublishOptions { RoutingKey = message.EventName },
cancellationToken);
return Ok(ApiResult.Ok("消息已发布;RabbitMQ 未启用时会安全跳过"));
}
请求地址是 POST /api/demo/messages。它主要用于在 Swagger 中单独确认消息发布链路。
九、文件上传:文件名不能直接当保存路径
文件 Demo 的代码在 DemoFileController 中。保存文件时只取扩展名,真正的文件名用 GUID 生成:
[HttpPost]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult<ApiResult<object>>> Upload(
IFormFile file,
CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
throw new BusinessException("请选择非空文件");
if (file.Length > 10 * 1024 * 1024)
throw new BusinessException("文件不能超过 10MB");
var extension = Path.GetExtension(file.FileName);
var path = $"demo/{DateTime.UtcNow:yyyyMMdd}/{Guid.NewGuid():N}{extension}";
await using var stream = file.OpenReadStream();
var savedPath = await fileStorage.SaveAsync(path, stream, cancellationToken);
return Ok(ApiResult<object>.Ok(new
{
path = savedPath,
downloadUrl = $"/api/demo/files/{savedPath}"
}, "上传成功"));
}
如果直接把 file.FileName 拼到物理路径里,就会遇到重名覆盖、特殊字符和路径穿越等问题。业务层传给 IFileStorage 的只是相对路径,存储封装还会检查它没有跑出根目录。
下载接口为了方便 Swagger 调试使用了匿名访问。真实项目不能直接照搬,应该根据文件所属业务单据和当前用户权限判断是否允许下载。
十、SignalR 广播
SignalR 的 Hub 地址是 /appHub。Demo 提供两个接口,一个看在线人数,一个发送广播:
[HttpGet("online-count")]
[Permission("demo.read")]
public ActionResult<ApiResult<object>> GetOnlineCount()
=> Ok(ApiResult<object>.Ok(new
{
onlineUserCount = connections.OnlineUserCount
}));
[HttpPost("broadcast")]
public async Task<ActionResult<ApiResult>> Broadcast(
[FromBody] DemoBroadcastRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
throw new BusinessException("消息不能为空");
await hub.Clients.All.SendAsync(
"OnMessage",
"demo.broadcast",
new
{
message = request.Message.Trim(),
sentAt = DateTimeOffset.UtcNow
});
return Ok(ApiResult.Ok("广播已发送"));
}
客户端连接 Hub 后监听 OnMessage 就能收到消息。这里使用 Clients.All 是为了让 Demo 直观,实际业务通常应该发给指定用户、租户或群组,不能把仓库通知发给所有在线用户。
十一、Quartz 定时任务
项目里还有一个每五分钟执行一次的维护任务:
[QuartzJob(
"DemoMaintenanceJob",
Cron = "0 0/5 * * * ?",
Description = "全功能 Demo:每五分钟执行一次维护日志任务")]
public sealed class DemoMaintenanceJob(
ILogger<DemoMaintenanceJob> logger) : IJob
{
public Task Execute(IJobExecutionContext context)
{
logger.LogInformation(
"Demo Quartz maintenance job executed at {ExecutedAt}",
DateTimeOffset.UtcNow);
return Task.CompletedTask;
}
}
它目前只输出一条结构化日志,不改数据库,也不产生其他副作用。启动应用后看日志,等任务执行一次,就能确认 Quartz 扫描、注册和调度都正常。
实际项目中,可以把这里换成生成报表、清理临时文件或检查超时订单。任务类放在 Application 程序集,是因为启动时的 Quartz 注册会扫描这个程序集。
十二、从启动到请求的完整过程
第一次运行时,我建议按这个顺序调试:
cp .env.example .env
# 修改数据库、RabbitMQ、JWT 配置
docker compose up -d
dotnet restore
dotnet build WebApplication.sln -c Release
dotnet test WebApplication.sln -c Release
dotnet run --project HQ.Application -c Release
应用启动后:
- 先访问
/health和/health/ready,确认进程和数据库都正常。 - 调用
POST /api/demo/auth/token获取 Token。 - 在 Swagger 中填写 Bearer Token。
- 调用
GET /api/demo/todos,第一次会查数据库并建立缓存。 - 调用
POST /api/demo/todos,观察数据库新增、消息发布和缓存删除。 - 再次查询列表,确认写入后的数据能够重新加载。
- 继续测试更新、删除、文件上传、SignalR 广播。
- 查看日志,确认 Quartz 任务执行。
创建待办时,完整链路就是:Token 通过权限检查,Controller 调用 Service,Service 校验标题,仓储写入数据库,UnitOfWork 结束后清理列表缓存,同时发布 todo.created 事件,最后把实体转换成 DTO 返回。
十三、Demo 里有几处不要直接复制到生产
- 登录接口不校验密码,只是为了调试方便。
- 数据库和 RabbitMQ 没有通过 Outbox 做最终一致性。
- 文件下载接口是匿名的,生产环境要做文件权限控制。
- SignalR 使用全量广播,正式业务应按用户或业务范围发送。
- CodeFirst 自动建表适合本地 Demo,生产库要有可追踪的迁移记录。
- 删除使用物理删除,是否保留审计记录要由业务决定。
这些限制不是 Demo 的缺陷,而是为了让示例保持简单。把它跑通以后,再针对自己的业务补上认证、审计、幂等、重试和权限细节,会比一开始就堆一套复杂模板更容易排查问题。
总结
这个 Demo 的重点不是待办本身,而是一个业务请求如何使用 HQServer 的基础设施。Controller 保持简单,Service 负责把仓储、事务、缓存和消息串起来,文件、SignalR、Quartz 则分别提供了几个常见的扩展点。
如果准备在 HQServer 上新建业务模块,可以先照着待办模块复制目录结构,再把实体、DTO 和业务规则替换成自己的内容。基础能力尽量继续使用 Common 层已有的封装,不要在每个业务里重新写一遍数据库事务、Token 解析或 RabbitMQ 连接管理。










暂无评论内容