文章目录
- 项目地址
- 一、外部导入打卡功能
- 1.1 创建实体
- 1. Entry实体
- 2. EntryImport实体
- 3. 添加数据库配置
- 4. 创建表
- 1.2 创建DTOs
- 1.3 创建GetEnties Controller
- 二、游标分页
- 2.1 创建所需要的DTOs
- 1. 创建游标分页的请求参数
- 2. 创建CollectionResponse
- 3. 添加游标编码和解码的DTO
- 2.2 创建游标查询的Controller
- 1. 传入带游标的query
- 2. 对query里的游标解码查询
- 3. 数据查询逻辑
- 2.3 测试
- 三、Refit
- 3.1 安装需要的包
- 3.2 创建接口IGitHubApi
- 3.3 创建RefitGitHubService
- 3.4 修改使用方法
- 四、Http resilience
- 4.1 安装所需要的包
- 4.2 创建resilience pipeline简单版
- 4.3 创建全局的resilience处理
- 1. 创建清理全局ResilienceHandler
- 2. 添加全局resilience
- 3. 添加自定义的resilience策略
- 4. 使用自定义策略
- XXX
- 1.5 创建缓存属性过滤器Idempotent Request
- 1. 创建户型过滤器
- 2. 创建CreateEntry Controller
- 1.6 批量提交Entry
- 1. 创建Dto
- 2. 创建批量提交的Controller
- 1.7 Quartz后台任务调度
- 1. 给Habit添加AutomationSource
- 2. 创建定时任务
- 3. 创建GitHubHabitProcessorJob
- 1.8 Hateoas Driven
- 1. 前后端解耦
- 2. 后端控制前端页面
项目地址
- 教程作者:
- 教程地址:
- 代码仓库地址:
- 所用到的框架和插件:
dbt
airflow
一、外部导入打卡功能
1.1 创建实体
1. Entry实体
用来记录打卡
public sealed class Entry
{public string Id { get; set; } // 条目的唯一标识符,例如 "e_xxxxx"public string HabitId { get; set; } // 所属的习惯 ID(外键,关联 Habit 表)public string UserId { get; set; } // 该条目所属用户的 IDpublic 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实体
- 通过文件上传打卡记录,
public sealed class EntryImportJob
{public string Id { get; set; } // 导入任务的唯一标识符,例如 "ei_xxxxx"public string UserId { get; set; } // 执行导入任务的用户 IDpublic 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表
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 namebuilder.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.返回结果
[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用来记录上一页的位置
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的实体
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的编码和解码,防止数据泄密
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. 数据查询逻辑
- 获取比Limit多的数据11条
- 如果数据大于10条,说明还有下一页
- 将最后一条数据的id和Date,编码为下一个游标
- 去除掉多余的+1的数据,保证每次10条
- 将数据返回给前端
2.3 测试
- 当我们发起一个需要100条数据的请求,如果还有下一页的数据,将会得到有next-page的links
三、Refit
3.1 安装需要的包
3.2 创建接口IGitHubApi
- 用 Refit 自动帮你生成访问 GitHub API 的客户端
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# 里的驼峰 PerPageCancellationToken cancellationToken = default);
}
- 注册该服务
3.3 创建RefitGitHubService
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
//注册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,但是部分服务又想执行自己的熔断措施,就需要先清理当前全局的措施,在添加自己的
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 秒再发送。
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. 创建户型过滤器
- 创建Services
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添加修饰器
[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进行判断,如果重复或者没有,报错
POST /orders
Idempotency-Key: d2b7e35c-5e23-4e1d-94cf-3acda6b9b5b3
1.6 批量提交Entry
1. 创建Dto
namespace DevHabit.Api.DTOs.Entries;
public sealed record CreateEntryBatchDto
{public required List<CreateEntryDto> Entries { get; init; }
}
2. 创建批量提交的Controller
[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数量不一致,说明有无效的习惯IDif (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 转换为返回给前端的 DTOvar 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后台任务调度
- 安装Quartz
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.14.0" />
- 使用教程以及持久化方案
https://www.bilibili.com/video/BV1c8AVerEQY/?spm_id_from=333.337.search-card.all.click&vd_source=791e6deaa9c8a56b1f845a0bc1431b71
1. 给Habit添加AutomationSource
- 添加AutomationSource枚举类型字段,表示自动化数据来源
2. 创建定时任务
- 定期扫描数据库中启用了 GitHub 自动化的习惯(Habit)数据,为每个符合条件的 habit 创建并立即执行一个 GitHubHabitProcessorJob(处理任务)。
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 executionITrigger trigger = TriggerBuilder.Create().WithIdentity($"github-habit-{habit.Id}", "github-habits").StartNow().Build();// Create the job with habit dataIJobDetail jobDetail = JobBuilder.Create<GitHubHabitProcessorJob>().WithIdentity($"github-habit-{habit.Id}", "github-habits").UsingJobData("habitId", habit.Id).Build();// Schedule the jobawait 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
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){//取出当前任务对应的 HabitIdstring 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 automationHabit? 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 tokenstring? 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 profileGitHubUserProfileDto? 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 eventsList<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 eventsvar 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 eventbool 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 entryvar entry = new Entry{Id = $"e_{Guid.CreateVersion7()}",HabitId = habitId,UserId = habit.UserId,Value = 1, // Each push counts as 1Notes =$"""{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
- 场景:
- 切换标签,显示该页面的links
- 根据当前页面的某些条件,例如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的返回内容,来控制