(六)RestAPI 毛子(外部导入打卡/游标分页/Refit/Http resilience/批量提交/Quartz后台任务/Hateoas Driven)


文章目录

  • 项目地址
  • 一、外部导入打卡功能
    • [1.1 创建实体](#1.1 创建实体)
      • [1. Entry实体](#1. Entry实体)
      • [2. EntryImport实体](#2. EntryImport实体)
      • [3. 添加数据库配置](#3. 添加数据库配置)
      • [4. 创建表](#4. 创建表)
    • [1.2 创建DTOs](#1.2 创建DTOs)
    • [1.3 创建GetEnties Controller](#1.3 创建GetEnties Controller)
  • 二、游标分页
    • [2.1 创建所需要的DTOs](#2.1 创建所需要的DTOs)
      • [1. 创建游标分页的请求参数](#1. 创建游标分页的请求参数)
      • [2. 创建CollectionResponse](#2. 创建CollectionResponse)
      • [3. 添加游标编码和解码的DTO](#3. 添加游标编码和解码的DTO)
    • [2.2 创建游标查询的Controller](#2.2 创建游标查询的Controller)
      • [1. 传入带游标的query](#1. 传入带游标的query)
      • [2. 对query里的游标解码查询](#2. 对query里的游标解码查询)
      • [3. 数据查询逻辑](#3. 数据查询逻辑)
      • [2.3 测试](#2.3 测试)
  • 三、Refit
    • [3.1 安装需要的包](#3.1 安装需要的包)
    • [3.2 创建接口IGitHubApi](#3.2 创建接口IGitHubApi)
    • [3.3 创建RefitGitHubService](#3.3 创建RefitGitHubService)
    • [3.4 修改使用方法](#3.4 修改使用方法)
  • [四、Http resilience](#四、Http resilience)
    • [4.1 安装所需要的包](#4.1 安装所需要的包)
    • [4.2 创建resilience pipeline简单版](#4.2 创建resilience pipeline简单版)
    • [4.3 创建全局的resilience处理](#4.3 创建全局的resilience处理)
      • [1. 创建清理全局ResilienceHandler](#1. 创建清理全局ResilienceHandler)
      • [2. 添加全局resilience](#2. 添加全局resilience)
      • [3. 添加自定义的resilience策略](#3. 添加自定义的resilience策略)
      • [4. 使用自定义策略](#4. 使用自定义策略)
  • XXX
    • [1.5 创建缓存属性过滤器Idempotent Request](#1.5 创建缓存属性过滤器Idempotent Request)
      • [1. 创建户型过滤器](#1. 创建户型过滤器)
      • [2. 创建CreateEntry Controller](#2. 创建CreateEntry Controller)
    • [1.6 批量提交Entry](#1.6 批量提交Entry)
      • [1. 创建Dto](#1. 创建Dto)
      • [2. 创建批量提交的Controller](#2. 创建批量提交的Controller)
    • [1.7 Quartz后台任务调度](#1.7 Quartz后台任务调度)
      • [1. 给Habit添加AutomationSource](#1. 给Habit添加AutomationSource)
      • [2. 创建定时任务](#2. 创建定时任务)
      • [3. 创建GitHubHabitProcessorJob](#3. 创建GitHubHabitProcessorJob)
    • [1.8 Hateoas Driven](#1.8 Hateoas Driven)
      • [1. 前后端解耦](#1. 前后端解耦)
      • [2. 后端控制前端页面](#2. 后端控制前端页面)

项目地址

  • 教程作者:
  • 教程地址:
复制代码
  • 代码仓库地址:
复制代码
  • 所用到的框架和插件:

    dbt
    airflow

一、外部导入打卡功能

1.1 创建实体

1. Entry实体

用来记录打卡

cs 复制代码
public sealed class Entry
{
    public string Id { get; set; } // 条目的唯一标识符,例如 "e_xxxxx"
    public string HabitId { get; set; } // 所属的习惯 ID(外键,关联 Habit 表)
    public string UserId { get; set; } // 该条目所属用户的 ID
    public int Value { get; set; } // 数值,比如某习惯的完成次数、时长等
    public string? Notes { get; set; } // 备注信息,可选填写
    public EntrySource Source { get; init; } // 条目的来源(手动、自动、文件导入等)
    public string? ExternalId { get; init; } // 外部系统的 ID(用于去重或跟踪导入来源)
    public bool IsArchived { get; set; } // 是否被归档,不再显示在活跃列表中
    public DateOnly Date { get; set; } // 条目的日期(例如 2025-04-21)
    public DateTime CreatedAtUtc { get; set; } // 创建时间(UTC 时间)
    public DateTime? UpdatedAtUtc { get; set; } // 最后更新时间(可为空)

    public Habit Habit { get; set; } // 导航属性,用于 Entity Framework 中的关联查询

    public static string NewId()
    {
        return $"e_{Guid.CreateVersion7()}"; // 使用 Guid v7 创建带前缀的唯一 ID,例如 "e_018fa..."
    }
}

public enum EntrySource
{
    Manual = 0,      // 手动添加
    Automation = 1,  // 通过系统自动生成(例如 GitHub 数据等)
    FileImport = 2   // 通过导入文件生成
}

2. EntryImport实体

  • 通过文件上传打卡记录,
cs 复制代码
public sealed class EntryImportJob
{
    public string Id { get; set; } // 导入任务的唯一标识符,例如 "ei_xxxxx"
    public string UserId { get; set; } // 执行导入任务的用户 ID
    public EntryImportStatus Status { get; set; } // 当前导入任务的状态(等待/处理中/完成/失败)
    public string FileName { get; set; } // 上传的文件名称(如 habits.csv)
    public byte[] FileContent { get; set; } // 文件的原始内容(二进制格式,便于存储/处理)
    public int TotalRecords { get; set; } // 文件中预期要导入的记录总数
    public int ProcessedRecords { get; set; } // 实际已处理的记录数量
    public int SuccessfulRecords { get; set; } // 成功导入的记录数
    public int FailedRecords { get; set; } // 导入失败的记录数
    public List<string> Errors { get; set; } = []; // 导入过程中的错误信息集合
    public DateTime CreatedAtUtc { get; set; } // 创建时间(上传时间,使用 UTC)
    public DateTime? CompletedAtUtc { get; set; } // 完成时间(仅在导入完成后有值)

    public static string NewId()
    {
        return $"ei_{Guid.CreateVersion7()}"; // 使用 Guid v7 创建带前缀的唯一 ID,例如 "ei_018fa..."
    }
}

public enum EntryImportStatus
{
    Pending,     // 等待开始处理
    Processing,  // 正在处理
    Completed,   // 处理完成
    Failed       // 处理失败(可能是文件损坏、格式错误等)
}

3. 添加数据库配置

  • Entry表
cs 复制代码
public sealed class EntryConfiguration : IEntityTypeConfiguration<Entry>
{
    public void Configure(EntityTypeBuilder<Entry> builder)
    {
        builder.HasKey(e => e.Id);

        builder.Property(e => e.Id).HasMaxLength(500);
        builder.Property(e => e.HabitId).HasMaxLength(500);
        builder.Property(e => e.UserId).HasMaxLength(500);

        builder.Property(e => e.Notes).HasMaxLength(1000);
        builder.Property(e => e.ExternalId).HasMaxLength(1000);

        builder.HasOne(e => e.Habit)
            .WithMany()
            .HasForeignKey(e => e.HabitId);

        builder.HasOne<User>()
            .WithMany()
            .HasForeignKey(e => e.UserId);

        // We have to match snake_case naming convention for the column name
        builder.HasIndex(e => e.ExternalId)
            .IsUnique()
            .HasFilter("external_id IS NOT NULL");
    }
}

4. 创建表

  • 进行迁移

    Add-Migration Add_Entry -Context ApplicationDbContext -o Migrations/Application

1.2 创建DTOs

1.3 创建GetEnties Controller

  • 创建GetEntries流程梳理

    1. 传入请求的参数 [FromQuery] EntriesQueryParameters query以及需要的服务
    2. 获取当前用户信息并且判断用户是否存在
    3. 验证EntriesQueryParameters 里的Sort和Fields是否合法
    4. 根据排序信息,进行排序映射
    5. 使用IQueryable 延迟查询
    6. 获取所有数据条数,用于分页
    7. 进行排序,查询
    8. 对查询结果进行分页
    9. 判断是否有Hateoas
      10.返回结果
cs 复制代码
    [HttpGet]
    public async Task<IActionResult> GetEntries(
        [FromQuery] EntriesQueryParameters query,
        SortMappingProvider sortMappingProvider,
        DataShapingService dataShapingService)
    {
        string? userId = await userContext.GetUserIdAsync();
        if (string.IsNullOrWhiteSpace(userId))
        {
            return Unauthorized();
        }

        if (!sortMappingProvider.ValidateMappings<EntryDto, Entry>(query.Sort))
        {
            return Problem(
                statusCode: StatusCodes.Status400BadRequest,
                detail: $"The provided sort parameter isn't valid: '{query.Sort}'");
        }

        if (!dataShapingService.Validate<EntryDto>(query.Fields))
        {
            return Problem(
                statusCode: StatusCodes.Status400BadRequest,
                detail: $"The provided data shaping fields aren't valid: '{query.Fields}'");
        }

        SortMapping[] sortMappings = sortMappingProvider.GetMappings<EntryDto, Entry>();

        IQueryable<Entry> entriesQuery = dbContext.Entries
            .Where(e => e.UserId == userId)
            .Where(e => query.HabitId == null || e.HabitId == query.HabitId)
            .Where(e => query.FromDate == null || e.Date >= query.FromDate)
            .Where(e => query.ToDate == null || e.Date <= query.ToDate)
            .Where(e => query.Source == null || e.Source == query.Source)
            .Where(e => query.IsArchived == null || e.IsArchived == query.IsArchived);

        int totalCount = await entriesQuery.CountAsync();

        List<EntryDto> entries = await entriesQuery
            .ApplySort(query.Sort, sortMappings)
            .Skip((query.Page - 1) * query.PageSize)
            .Take(query.PageSize)
            .Select(EntryQueries.ProjectToDto())
            .ToListAsync();

        var paginationResult = new PaginationResult<ExpandoObject>
        {
            Items = dataShapingService.ShapeCollectionData(
                entries,
                query.Fields,
                query.IncludeLinks ? e => CreateLinksForEntry(e.Id, query.Fields, e.IsArchived) : null),
            Page = query.Page,
            PageSize = query.PageSize,
            TotalCount = totalCount
        };

        if (query.IncludeLinks)
        {
            paginationResult.Links = CreateLinksForEntries(
                query,
                paginationResult.HasNextPage,
                paginationResult.HasPreviousPage);
        }

        return Ok(paginationResult);
    }

二、游标分页

2.1 创建所需要的DTOs

1. 创建游标分页的请求参数

  • Curor主要是一个index用来记录上一页的位置
cs 复制代码
namespace DevHabit.Api.DTOs.Entries;
public sealed record EntriesCursorQueryParameters : AcceptHeaderDto
{
    public string? Cursor { get; init; }
    public string? Fields { get; init; }
    public string? HabitId { get; init; }
    public DateOnly? FromDate { get; init; }
    public DateOnly? ToDate { get; init; }
    public EntrySource? Source { get; init; }
    public bool? IsArchived { get; init; }
    public int Limit { get; init; } = 10;
}

2. 创建CollectionResponse

  • 该实体用来表示表示含有items和links的实体
cs 复制代码
namespace DevHabit.Api.DTOs.Common;
public sealed class CollectionResponse<T> : ICollectionResponse<T>, ILinksResponse
{
    public List<T> Items { get; init; }
    public List<LinkDto> Links { get; set; }
}
  • 数据结构为:

3. 添加游标编码和解码的DTO

  • 将最后一条数据的Id和时间进行base64的编码和解码,防止数据泄密
cs 复制代码
namespace DevHabit.Api.DTOs.Entries;
public sealed record EntryCursorDto(string Id, DateOnly Date)
{
    //将一个游标(ID 和时间)编码为字符串,前端分页请求时可用
    public static string Encode(string id, DateOnly date)
    {
        var cursor = new EntryCursorDto(id, date); // 创建一个游标对象
        string json = JsonSerializer.Serialize(cursor); // 序列化为 JSON 字符串
        return Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(json)); // 转成 Base64,避免 JSON 暴露或格式错误
    }
    public static EntryCursorDto Decode(string? cursor)
    {
        if (string.IsNullOrWhiteSpace(cursor))
        {
            return null;
        }
        try
        {
            string json = Base64UrlEncoder.Decode(cursor); //解码 Base64 字符串
            return JsonSerializer.Deserialize<EntryCursorDto>(json); // 反序列化回游标对象
        }
        catch
        {
            return null;
        }
    }
}

2.2 创建游标查询的Controller

流程梳理:

1. 传入带游标的query

2. 对query里的游标解码查询

  • 如果携带了游标,对游标进行解码,并且根据游标的信息查询数据

3. 数据查询逻辑

  1. 获取比Limit多的数据11条
  2. 如果数据大于10条,说明还有下一页
  3. 将最后一条数据的id和Date,编码为下一个游标
  4. 去除掉多余的+1的数据,保证每次10条
  5. 将数据返回给前端

2.3 测试

  • 当我们发起一个需要100条数据的请求,如果还有下一页的数据,将会得到有next-page的links

三、Refit

3.1 安装需要的包

3.2 创建接口IGitHubApi

  • 用 Refit 自动帮你生成访问 GitHub API 的客户端
cs 复制代码
namespace DevHabit.Api.Services;

//每次调用这个接口的时候,自动给 HTTP 请求带上这两个头
[Headers("User-Agent: DevHabit/1.0", "Accept: application/vnd.github+json")]
public interface IGitHubApi
{
    [Get("/user")] //GET 请求,访问的是 GitHub API 的 /user 路径
    Task<ApiResponse<GitHubUserProfileDto>> GetUserProfile(
        [Authorize(scheme: "Bearer")] string accessToken, //自动添加jwt Token在请求头中
        CancellationToken cancellationToken = default);

    [Get("/users/{username}/events")] //GET 请求,访问的是 GitHub API 的 /users/{username}/events 路径
    Task<ApiResponse<IReadOnlyList<GitHubEventDto>>> GetUserEvents(
        string username,
        [Authorize(scheme: "Bearer")] string accessToken, // accessToken自动插到请求头里(带身份认证)
        int page = 1,
        [AliasAs("per_page")] int perPage = 100, //告诉RefitGitHub API 要求参数名是 per_page(不是 C# 里的驼峰 PerPage
        CancellationToken cancellationToken = default);
}
  • 注册该服务

3.3 创建RefitGitHubService

cs 复制代码
using System.Net.Http.Headers;
using DevHabit.Api.DTOs.GitHub;
using Newtonsoft.Json;
using Refit;

namespace DevHabit.Api.Services;

public sealed class RefitGitHubService(IGitHubApi gitHubApi, ILogger<GitHubService> logger)
{
    public async Task<GitHubUserProfileDto?> GetUserProfileAsync(
        string accessToken,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrEmpty(accessToken);

        ApiResponse<GitHubUserProfileDto> response = await gitHubApi.GetUserProfile(accessToken, cancellationToken);

        if (!response.IsSuccessStatusCode)
        {
            logger.LogWarning("Failed to get user profile from GitHub. Status code: {StatusCode}", response.StatusCode);
            return null;
        }

        return response.Content;
    }

    public async Task<IReadOnlyList<GitHubEventDto>?> GetUserEventsAsync(
        string username,
        string accessToken,
        int page = 1,
        int perPage = 100,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrEmpty(accessToken);
        ArgumentException.ThrowIfNullOrEmpty(username);

        ApiResponse<IReadOnlyList<GitHubEventDto>> response =
            await gitHubApi.GetUserEvents(
                username,
                accessToken,
                page,
                perPage,
                cancellationToken);

        if (!response.IsSuccessStatusCode)
        {
            logger.LogWarning("Failed to get user events from GitHub. Status code: {StatusCode}", response.StatusCode);
            return null;
        }
        return response.Content;
    }
}
  • 注册该服务在DependencyInjection
cs 复制代码
//注册RefitGitHubService
builder.Services.AddTransient<RefitGitHubService>();

3.4 修改使用方法

  • 替换之前使用githubService方法的Controller

四、Http resilience

4.1 安装所需要的包

4.2 创建resilience pipeline简单版

  • 直接给需要使用的地方添加,这里我们使用refit获取第三方github的api数据,所以在该服务后面添加

4.3 创建全局的resilience处理

1. 创建清理全局ResilienceHandler

  • 如果我们配置了全局resilience,但是部分服务又想执行自己的熔断措施,就需要先清理当前全局的措施,在添加自己的
cs 复制代码
namespace DevHabit.Api.Extensions;
public static class ResilienceHttpClientBuilderExtensions
{
    public static IHttpClientBuilder InternalRemoveAllResilienceHandlers(this IHttpClientBuilder builder)
    {
        builder.ConfigureAdditionalHttpMessageHandlers(static (handlers, _) =>
        {
            for (int i = handlers.Count - 1; i >= 0; i--)
            {
                if (handlers[i] is ResilienceHandler)
                {
                    handlers.RemoveAt(i);
                }
            }
        });
        return builder;
    }
}
  • 使用: 在需要清除的服务,先清除

2. 添加全局resilience

  • 直接在服务里使用微软的包即可

3. 添加自定义的resilience策略

  • 如果上面的包里的方法不够使用,我们可以添加自己的策略;
  • 创建自己的测试策略:在 HttpClient 发送每一个请求前,强制延迟 10 秒再发送。
cs 复制代码
 namespace DevHabit.Api.Services;

public sealed class DelayHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        await Task.Delay(10000, cancellationToken);
        return await base.SendAsync(request, cancellationToken);
    }
}

4. 使用自定义策略

XXX

1.5 创建缓存属性过滤器Idempotent Request

  • 这个特性让你的某些接口(如创建订单、提交支付)变成"只处理一次",防止用户刷新或网络重复请求时多次处理。

1. 创建户型过滤器

  1. 创建Services
cs 复制代码
namespace DevHabit.Api.Services;

[AttributeUsage(AttributeTargets.Method)]
public sealed class IdempotentRequestAttribute : Attribute, IAsyncActionFilter
{
    private const string IdempotenceKeyHeader = "Idempotency-Key";
    private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(60);

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        if (!context.HttpContext.Request.Headers.TryGetValue(
                IdempotenceKeyHeader,
                out StringValues idempotenceKeyValue) ||
            !Guid.TryParse(idempotenceKeyValue, out Guid idempotenceKey))
        {
            ProblemDetailsFactory problemDetailsFactory = context.HttpContext.RequestServices
                .GetRequiredService<ProblemDetailsFactory>();

            ProblemDetails problemDetails = problemDetailsFactory.CreateProblemDetails(
                context.HttpContext,
                statusCode: StatusCodes.Status400BadRequest,
                title: "Bad Request",
                detail: $"Invalid or missing {IdempotenceKeyHeader} header");

            context.Result = new BadRequestObjectResult(problemDetails);
            return;
        }
        // In production code you would want to use some kind of distributed cache. This is just for proof of concept. 
        IMemoryCache cache = context.HttpContext.RequestServices.GetRequiredService<IMemoryCache>();
        string cacheKey = $"IdempotentRequest:{idempotenceKey}";
        int? statusCode = cache.Get<int?>(cacheKey);
        if (statusCode is not null)
        {
            var result = new StatusCodeResult(statusCode.Value);
            context.Result = result;
            return;
        }
        ActionExecutedContext executedContext = await next();
        if (executedContext.Result is ObjectResult objectResult)
        {
            cache.Set(cacheKey, objectResult.StatusCode, DefaultCacheDuration);
        }
    }
}

2. 创建CreateEntry Controller

  • 在CreateEntry添加修饰器
cs 复制代码
    [HttpPost]
    [IdempotentRequest]
    public async Task<ActionResult<EntryDto>> CreateEntry(
        CreateEntryDto createEntryDto,
        [FromHeader] AcceptHeaderDto acceptHeader,
        IValidator<CreateEntryDto> validator)
    {
        string? userId = await userContext.GetUserIdAsync();
        if (string.IsNullOrWhiteSpace(userId))
        {
            return Unauthorized();
        }

        await validator.ValidateAndThrowAsync(createEntryDto);

        Habit? habit = await dbContext.Habits
            .FirstOrDefaultAsync(h => h.Id == createEntryDto.HabitId && h.UserId == userId);

        if (habit is null)
        {
            return Problem(
                detail: $"Habit with ID '{createEntryDto.HabitId}' does not exist.",
                statusCode: StatusCodes.Status400BadRequest);
        }

        Entry entry = createEntryDto.ToEntity(userId, habit);
        dbContext.Entries.Add(entry);
        await dbContext.SaveChangesAsync();

        EntryDto entryDto = entry.ToDto();

        if (acceptHeader.IncludeLinks)
        {
            entryDto.Links = CreateLinksForEntry(entry.Id, null, entry.IsArchived);
        }

        return CreatedAtAction(nameof(GetEntry), new { id = entryDto.Id }, entryDto);
    }
  • 前端请求时带上一个唯一的 Header,会根据Idempotency-Key进行判断,如果重复或者没有,报错
cs 复制代码
POST /orders
Idempotency-Key: d2b7e35c-5e23-4e1d-94cf-3acda6b9b5b3

1.6 批量提交Entry

1. 创建Dto

cs 复制代码
namespace DevHabit.Api.DTOs.Entries;
public sealed record CreateEntryBatchDto
{
    public required List<CreateEntryDto> Entries { get; init; }
}

2. 创建批量提交的Controller

cs 复制代码
    [HttpPost("batch")]
    public async Task<ActionResult<List<EntryDto>>> CreateEntryBatch(
        CreateEntryBatchDto createEntryBatchDto,
        [FromHeader] AcceptHeaderDto acceptHeader,
        IValidator<CreateEntryBatchDto> validator)
    {
        string? userId = await userContext.GetUserIdAsync();
        if (string.IsNullOrWhiteSpace(userId))
        {
            return Unauthorized();
        }

        await validator.ValidateAndThrowAsync(createEntryBatchDto);

        //收集所有 entry 中使用到的 HabitId,防止重复
        var habitIds = createEntryBatchDto.Entries
            .Select(e => e.HabitId)
            .ToHashSet();

        //从数据库中查询这些习惯是否属于当前用户
        List<Habit> existingHabits = await dbContext.Habits
            .Where(h => habitIds.Contains(h.Id) && h.UserId == userId)
            .ToListAsync();

        //如果查询到的习惯数量和传入的习惯ID数量不一致,说明有无效的习惯ID
        if (existingHabits.Count != habitIds.Count)
        {
            return Problem(
                detail: "One or more habit IDs is invalid",
                statusCode: StatusCodes.Status400BadRequest);
        }

        //将 DTO 转换为 Entity 对象,准备写入数据库
        var entries = createEntryBatchDto.Entries
            .Select(dto => dto.ToEntity(userId, existingHabits.First(h => h.Id == dto.HabitId)))
            .ToList();

        dbContext.Entries.AddRange(entries);
        await dbContext.SaveChangesAsync();

        //把 Entity 转换为返回给前端的 DTO
        var entryDtos = entries.Select(e => e.ToDto()).ToList();

        if (acceptHeader.IncludeLinks)
        {
            foreach (EntryDto entryDto in entryDtos)
            {
                entryDto.Links = CreateLinksForEntry(entryDto.Id, null, entryDto.IsArchived);
            }
        }

        return CreatedAtAction(nameof(GetEntries), entryDtos);
    }

1.7 Quartz后台任务调度

1. 给Habit添加AutomationSource

  • 添加AutomationSource枚举类型字段,表示自动化数据来源

2. 创建定时任务

  • 定期扫描数据库中启用了 GitHub 自动化的习惯(Habit)数据,为每个符合条件的 habit 创建并立即执行一个 GitHubHabitProcessorJob(处理任务)。
cs 复制代码
namespace DevHabit.Api.Jobs;

[DisallowConcurrentExecution]
public sealed class GitHubAutomationSchedulerJob(
    ApplicationDbContext dbContext,
    ILogger<GitHubAutomationSchedulerJob> logger) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        try
        {
            logger.LogInformation("Starting GitHub automation scheduler job");

            //找到所有是AutomationSource=1的集合
            List<Habit> habitsToProcess = await dbContext.Habits
                .Where(h => h.AutomationSource == AutomationSource.GitHub && !h.IsArchived)
                .ToListAsync(context.CancellationToken);

            logger.LogInformation("Found {Count} habits with GitHub automation", habitsToProcess.Count);

            foreach (Habit habit in habitsToProcess)
            {
                // Create a trigger for immediate execution
                ITrigger trigger = TriggerBuilder.Create()
                    .WithIdentity($"github-habit-{habit.Id}", "github-habits")
                    .StartNow()
                    .Build();

                // Create the job with habit data
                IJobDetail jobDetail = JobBuilder.Create<GitHubHabitProcessorJob>()
                    .WithIdentity($"github-habit-{habit.Id}", "github-habits")
                    .UsingJobData("habitId", habit.Id)
                    .Build();

                // Schedule the job
                await context.Scheduler.ScheduleJob(jobDetail, trigger, context.CancellationToken);
                logger.LogInformation("Scheduled processor job for habit {HabitId}", habit.Id);
            }

            logger.LogInformation("Completed GitHub automation scheduler job");
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error executing GitHub automation scheduler job");
            throw;
        }
    }
}

3. 创建GitHubHabitProcessorJob

cs 复制代码
using DevHabit.Api.Database;
using DevHabit.Api.DTOs.GitHub;
using DevHabit.Api.Entities;
using DevHabit.Api.Services;
using Microsoft.EntityFrameworkCore;
using Quartz;

namespace DevHabit.Api.Jobs;


public sealed class GitHubHabitProcessorJob(
    ApplicationDbContext dbContext,
    GitHubAccessTokenService gitHubAccessTokenService,
    RefitGitHubService gitHubService,
    ILogger<GitHubHabitProcessorJob> logger) : IJob
{
    private const string PushEventType = "PushEvent";

    public async Task Execute(IJobExecutionContext context)
    {
        //取出当前任务对应的 HabitId
        string habitId = context.JobDetail.JobDataMap.GetString("habitId")
            ?? throw new InvalidOperationException("HabitId not found in job data");

        try
        {
            logger.LogInformation("Processing GitHub events for habit {HabitId}", habitId);

            // Get the habit and ensure it still exists and is configured for GitHub automation
            Habit? habit = await dbContext.Habits
                .FirstOrDefaultAsync(h => h.Id == habitId &&
                    h.AutomationSource == AutomationSource.GitHub &&
                    !h.IsArchived, context.CancellationToken);

            if (habit == null)
            {
                logger.LogWarning("Habit {HabitId} not found or no longer configured for GitHub automation", habitId);
                return;
            }

            // Get the user's GitHub access token
            string? accessToken = await gitHubAccessTokenService.GetAsync(habit.UserId, context.CancellationToken);

            if (string.IsNullOrWhiteSpace(accessToken))
            {
                logger.LogWarning("No GitHub access token found for user {UserId}", habit.UserId);
                return;
            }

            // Get GitHub profile
            GitHubUserProfileDto? profile = await gitHubService.GetUserProfileAsync(accessToken, context.CancellationToken);

            if (profile == null)
            {
                logger.LogWarning("Couldn't retrieve GitHub profile for user {UserId}", habit.UserId);
                return;
            }

            // Get GitHub events
            List<GitHubEventDto> gitHubEvents = [];
            const int perPage = 100;
            const int pagesToFetch = 10;

            //调用 Refit 封装的 GitHub API 拿到用户最近的 GitHub 活动(默认最多抓取 1000 条)
            for (int page = 1; page <= pagesToFetch; page++)
            {
                IReadOnlyList<GitHubEventDto>? pageEvents = await gitHubService.GetUserEventsAsync(
                    profile.Login,
                    accessToken,
                    page,
                    perPage,
                    context.CancellationToken);

                if (pageEvents is null || !pageEvents.Any())
                {
                    break;
                }

                gitHubEvents.AddRange(pageEvents);
            }

            if (!gitHubEvents.Any())
            {
                logger.LogWarning("Couldn't retrieve GitHub events for user {UserId}", habit.UserId);
                return;
            }

            // Filter to push events
            var pushEvents = gitHubEvents
                .Where(e => e.Type == PushEventType)
                .ToList();

            logger.LogInformation("Found {Count} push events for habit {HabitId}", pushEvents.Count, habitId);

            foreach (GitHubEventDto gitHubEventDto in pushEvents)
            {
                // Check if we already have an entry for this event
                bool exists = await dbContext.Entries.AnyAsync(
                    e => e.HabitId == habitId &&
                    e.ExternalId == gitHubEventDto.Id,
                    context.CancellationToken);

                if (exists)
                {
                    logger.LogDebug("Entry already exists for event {EventId}", gitHubEventDto.Id);
                    continue;
                }

                // Create a new entry
                var entry = new Entry
                {
                    Id = $"e_{Guid.CreateVersion7()}",
                    HabitId = habitId,
                    UserId = habit.UserId,
                    Value = 1, // Each push counts as 1
                    Notes =
                        $"""
                        {gitHubEventDto.Actor.Login} pushed:

                        {string.Join(
                            Environment.NewLine,
                            //从 GitHub 的 PushEvent 中提取 commit 信息
                            gitHubEventDto.Payload.Commits?.Select(c => $"- {c.Message}") ?? [])}
                        """,
                    Date = DateOnly.FromDateTime(gitHubEventDto.CreatedAt),
                    Source = EntrySource.Automation,
                    ExternalId = gitHubEventDto.Id,
                    CreatedAtUtc = DateTime.UtcNow
                };

                dbContext.Entries.Add(entry);
                logger.LogInformation(
                    "Created entry for event {EventId} on habit {HabitId}",
                    gitHubEventDto.Id,
                    habitId);
            }
            //保存到数据库
            await dbContext.SaveChangesAsync(context.CancellationToken);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error processing GitHub events for habit {HabitId}", habitId);
            throw;
        }
    }
}

1.8 Hateoas Driven

  • 场景:
    1. 切换标签,显示该页面的links
    2. 根据当前页面的某些条件,例如tags是否大于5条,控制create 按钮是否显示

1. 前后端解耦

  • 在展示用户的习惯列表时,根据后端返回的 self 链接动态生成每个习惯详情页的路由跳转地址,让前端和后端解耦,前端不需要硬编码 URL

    l.rel === 'self')?.href} to={new URL(habit.links.find(l=>l.rel==='self')?.href??'#').pathname}>
  • 实际生产的链接是

2. 后端控制前端页面

  • 通过控制CreateLinksTags的返回内容,来控制
相关推荐
码士集团小青30 分钟前
SpringBoot 集成缓存性能之王 Caffeine
spring boot·后端·缓存
Cry丶1 小时前
ubuntu安装vsftpd
服务器·网络·ubuntu
SHENGWEI胜为5 小时前
交换机端口显示正常,终端却获取不到 IP 地址?从 DHCP 流程定位故障
服务器·网络·测试工具·缓存·通信工程
IMPYLH7 小时前
Linux 系统备份与镜像
linux·服务器·网络
淘气包海鸟7 小时前
水声网络框架
网络·ns3·水
国科安芯8 小时前
星间激光通信终端CANFD控制网络的时延确定性与辐射可靠性
网络·嵌入式硬件·信息与通信·canfd·低轨卫星·商业航天·抗辐射
中屹指纹浏览器svip9 小时前
网络抖动会标记账号异常?分清两种波动
网络
承渊政道9 小时前
Linux网络学习【传输层协议UDP】
linux·网络·网络协议·学习·ubuntu·udp
小此方9 小时前
Linux网络(三十三):告别O(n)噩梦:从select/poll的挣扎到epoll红黑树+回调的降维打击,一文打通IO多路复用
linux·网络·驱动开发
2301_7779983410 小时前
Linux 网络:数据链路层
linux·服务器·网络