【ASP.NET CORE】14.HQServer全功能Demo使用说明

前言

前面几篇已经把 HQServer 的 JWT、Quartz、RabbitMQ 等基础功能接进来了。到这里,如果只看单个功能,可能觉得都不复杂;真正开始写业务时,问题往往出在它们怎么串起来。

所以这篇不再单独讲某一个组件,而是拿项目里现成的待办 Demo 走一遍。登录拿到 Token 以后,请求是怎样进入 Controller 的,权限在哪里判断,待办怎样写入数据库,缓存什么时候清理,RabbitMQ 的消息又从哪里发出去,都会结合代码说明。

Demo 代码在 HQ.Application/Application/DemoHQ.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

应用启动后:

  1. 先访问 /health/health/ready,确认进程和数据库都正常。
  2. 调用 POST /api/demo/auth/token 获取 Token。
  3. 在 Swagger 中填写 Bearer Token。
  4. 调用 GET /api/demo/todos,第一次会查数据库并建立缓存。
  5. 调用 POST /api/demo/todos,观察数据库新增、消息发布和缓存删除。
  6. 再次查询列表,确认写入后的数据能够重新加载。
  7. 继续测试更新、删除、文件上传、SignalR 广播。
  8. 查看日志,确认 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 连接管理。

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容