(六)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

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

    <Link key="https://api.example.com/habits/abc123" to="/habits/abc123">

2. 后端控制前端页面

  • 通过控制CreateLinksTags的返回内容,来控制
相关推荐
爱的叹息1 小时前
MyBatis缓存配置的完整示例,包含一级缓存、二级缓存、自定义缓存策略等核心场景,并附详细注释和总结表格
缓存·mybatis
weixin138233951792 小时前
EN18031测试,EN18031认证,EN18031报告解读
网络
JhonKI3 小时前
【Linux网络】构建与优化HTTP请求处理 - HttpRequest从理解到实现
linux·网络·http
GOATLong3 小时前
网络基础概念
linux·运维·服务器·网络·arm开发·c++
技术liul4 小时前
如何在iStoreOS DHCP中排除特定IP地址
网络·windows·tcp/ip
桃花岛主704 小时前
NAT穿透
服务器·网络·智能路由器
李宥小哥4 小时前
Redis01-基础-入门
缓存·中间件
sky.fly4 小时前
路由器重分发(OSPF+RIP),RIP充当翻译官,OSPF充当翻译官
网络·智能路由器
sky.fly4 小时前
思科路由器重分发(静态路由+OSPF动态路由+RIP动态路由)
大数据·网络·智能路由器