# AGENTS.md — ContentService Development Guide > **Mục đích:** File này hướng dẫn Agent (AI) cách làm việc đúng chuẩn với `ContentService`. > Đọc kỹ toàn bộ file này trước khi chỉnh sửa hoặc thêm bất kỳ code nào. --- ## 1. Tổng quan kiến trúc ``` ContentService (Port 5002) ├── ContentAPI/ │ ├── Domain/ # Entities (POCO – không có logic) │ ├── Features/ # Vertical Slice: mỗi use-case là 1 folder độc lập │ │ ├── Articles/ │ │ │ ├── CreateArticle/ │ │ │ ├── EditArticle/ │ │ │ ├── DeleteArticle/ │ │ │ ├── PublishArticle/ │ │ │ ├── GetArticle/ │ │ │ ├── GetArticles/ │ │ │ ├── GetFeaturedArticles/ │ │ │ ├── GetRelatedArticles/ │ │ │ ├── ListArticleByAuthor/ │ │ │ ├── ListArticleByTag/ │ │ │ ├── AdminListArticles/ │ │ │ ├── ToggleLike/ │ │ │ ├── GetLikeStatus/ │ │ │ ├── Management/ # Admin-only: Approve, Reject, Featured, WeeklyStats │ │ │ └── Shared/ # DTOs dùng chung giữa các Articles feature │ │ ├── Authors/ │ │ ├── Bookmarks/ │ │ ├── Categories/ │ │ ├── Comments/ │ │ ├── Image/ │ │ └── Tags/ │ ├── Hubs/ # SignalR hubs │ ├── Infrastructure/ │ │ ├── Abstraction/ # Interfaces (IFileStorage) │ │ ├── Config_Application/ # ServicesContainer – HttpClient, Polly retry │ │ ├── Config_Infrastructure/ # ServicesContainer – EF Core, FluentValidation, MediatR │ │ ├── Data/ # ContentDataSeeder – auto-migrate on startup │ │ ├── Extensions/ # ClaimsPrincipalExtensions │ │ └── Services/ # FirebaseStorageService, LocalFileStorage │ ├── Migrations/ │ ├── Program.cs # Entry point – đăng ký DI + map endpoints │ └── appsettings.json └── ContentService.Tests/ └── Features/ └── Articles/ └── Management/ # Unit tests cho management handlers ``` **Kiểu kiến trúc:** Vertical Slice Architecture (VSA) + Minimal API + MediatR + CQRS nhẹ. --- ## 2. Domain Entities > Nằm trong `ContentAPI/Domain/`. Đây là các POCO thuần tuý – **không thêm business logic vào đây**. | Entity | Mô tả | Các trường quan trọng | |---|---|---| | `Article` | Bài viết chính | `Id`, `AuthorId`, `Title`, `Slug` (unique), `Content`, `Summary`, `CoverImageUrl`, `IsPublished`, `IsRejected`, `IsFeatured`, `IsDelete`, `ViewCount`, `CategoryId` | | `Category` | Danh mục | `Id`, `Name`, `Slug` (unique) | | `Tag` | Nhãn | `Id`, `Name`, `Slug` (unique) | | `ArticleTag` | Bảng nối (M-M) | Composite key `(ArticleId, TagId)` | | `Comment` | Bình luận (hỗ trợ nested reply) | `Id`, `ArticleId`, `AuthorId`, `AuthorName`, `Content`, `ParentCommentId`, `IsDeleted` | | `ArticleLike` | Lượt like | Unique index `(ArticleId, UserId)` | | `Bookmark` | Bài viết đánh dấu | Unique index `(ArticleId, UserId)` | | `ArticleImage` | Ảnh bài viết | `Id`, `Url`, `AltText`, `ArticleId` | ### Quy tắc bắt buộc - **Không** đặt business logic vào entity. - **Không** thay đổi khóa chính (`Id`) của entity – luôn là `Guid`. - `Slug` phải **unique** và được tạo tự động từ `Title` qua hàm `Slugify()`. - Khi xóa mềm (soft-delete), **set `IsDelete = true`**, không xóa khỏi DB. - `AuthorId` là Guid từ **IdentityService** – ContentService **không lưu thông tin user**. --- ## 3. Luồng xử lý request (Request Pipeline) Mọi feature đều đi theo pipeline chuẩn sau: ``` HTTP Request │ ▼ [Endpoint] (Minimal API – file *Endpoint.cs) │ ► Extract user claims (GetUserId, GetUserName, IsAdminOrManagement) │ ► Map HTTP input → Command/Query │ ► Gọi IValidator.ValidateAsync() │ ► Nếu lỗi → trả ValidationProblem (400) │ ▼ [MediatR IMediator.Send()] │ ▼ [Handler] (file *Handler.cs) │ ► Truy vấn ContentDbContext (EF Core) │ ► Thực thi business logic │ ► Nếu cần → gọi IHubContext.Clients.All.SendAsync() │ ► Wrap kết quả trong Responses (từ SharedLibrary) │ ▼ [Endpoint trả HTTP Response] ├── 201 Created (tạo mới thành công) ├── 200 OK (thành công) ├── 400 BadRequest ├── 401 Unauthorized ├── 403 Forbidden └── 404 NotFound ``` --- ## 4. Cấu trúc file của một Feature (Chuẩn bắt buộc) Mỗi use-case **phải** có cấu trúc thư mục riêng trong `Features/{Domain}/{UseCaseName}/`. ### 4.1 Với Command (Write operation: POST, PUT, DELETE) ``` Features/Articles/CreateArticle/ CreateArticleCommand.cs # record – implements IRequest> CreateArticleRequest.cs # record – bind từ HTTP (FormData hoặc JSON body) CreateArticleResponse.cs # record – dữ liệu trả về CreateArticleValidator.cs # FluentValidation AbstractValidator CreateArticleHandler.cs # IRequestHandler> CreateArticleEndpoint.cs # static class, method Map(IEndpointRouteBuilder) ``` ### 4.2 Với Query (Read operation: GET) ``` Features/Articles/GetArticles/ GetArticlesQuery.cs # record – implements IRequest> GetArticlesHandler.cs # IRequestHandler> GetArticlesEndpoint.cs # static class, method Map(IEndpointRouteBuilder) ``` > **Ghi chú:** Một số query đơn giản không cần Validator. Chỉ thêm Validator khi có input cần kiểm tra. --- ## 5. Quy ước đặt tên | Loại file | Quy ước | Ví dụ | |---|---|---| | Command | `{Action}{Entity}Command` | `CreateArticleCommand` | | Query | `{Action}{Entity}Query` | `GetArticlesQuery` | | Handler | `{Action}{Entity}Handler` | `CreateArticleHandler` | | Endpoint | `{Action}{Entity}Endpoint` (static) | `CreateArticleEndpoint` | | Request | `{Action}{Entity}Request` | `CreateArticleRequest` | | Response | `{Action}{Entity}Response` | `CreateArticleResponse` | | Validator | `{Action}{Entity}Validator` | `CreateArticleValidator` | | Namespace | `ContentAPI.Features.{Domain}.{UseCase}` | `ContentAPI.Features.Articles.CreateArticle` | > ⚠️ **Không** đặt tên tùy tiện. Theo đúng convention trên để Project nhất quán. --- ## 6. Command & Query (CQRS) ### Command (ghi dữ liệu) ```csharp // Luôn dùng sealed record public sealed record CreateArticleCommand( Guid Id, Guid AuthorId, string Title, string Content, string? Summary, Guid? CategoryId, List? Tags, string? CoverImageUrl ) : IRequest>; ``` ### Query (đọc dữ liệu) ```csharp // Dùng record, có thể có default values public record GetArticlesQuery( int Page = 1, int PageSize = 10, string? Tag = null, Guid? AuthorId = null, string? Category = null, string? SearchTerm = null ) : IRequest>>; ``` **Quy tắc:** - Command và Query đều implement `IRequest` từ MediatR. - Response luôn wrap trong `Responses` hoặc trả trực tiếp kiểu primitive (`bool`) cho các handler đơn giản của Admin. --- ## 7. Handler ### Cấu trúc chuẩn ```csharp public class CreateArticleHandler : IRequestHandler> { private readonly ContentDbContext _context; private readonly IHubContext _hubContext; // Chỉ khi cần notify public CreateArticleHandler(ContentDbContext context, IHubContext hubContext) { _context = context; _hubContext = hubContext; } public async Task> Handle( CreateArticleCommand command, CancellationToken ct) { try { // 1. Business logic // 2. _context.SaveChangesAsync(ct) // 3. Broadcast SignalR nếu cần // 4. Return new Responses(true, "message", data) } catch (Exception ex) { LogExceptions.LogException(ex); return new Responses(false, "Error message"); } } } ``` **Quy tắc bắt buộc trong Handler:** - Luôn dùng `AsNoTracking()` cho query read-only. - Luôn `Include()` navigation property khi cần join. - Bắt `Exception` ở `catch` và log qua `LogExceptions.LogException(ex)` (từ SharedLibrary). - Trả `Responses(false, "message")` khi thất bại, không throw exception ra ngoài handler. - Sau khi SaveChanges, phát SignalR notify khi có sự kiện bài viết (Create, Approve, Reject, Featured). --- ## 8. Endpoint (Minimal API) ### Cấu trúc chuẩn ```csharp public static class CreateArticleEndpoint { public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app) { app.MapPost("/api/articles", async ([FromForm] CreateArticleRequest request, ClaimsPrincipal user, IValidator validator, IMediator mediator, IFileStorage fileStorage, CancellationToken ct) => { // 1. Lấy thông tin user từ JWT claims var authorId = user.GetUserId(); if (!authorId.HasValue) return Results.Unauthorized(); // 2. Xử lý file upload (nếu có) // 3. Map HTTP input → Command // 4. Validate var validation = await validator.ValidateAsync(command, ct); if (!validation.IsValid) return Results.ValidationProblem(validation.ToDictionary()); // 5. Send qua MediatR var result = await mediator.Send(command, ct); // 6. Trả response HTTP phù hợp return result.Flag ? Results.Created($"/api/articles/{result.Data!.Slug}", result) : Results.BadRequest(result); }) .WithTags("Articles") // Swagger tag .WithName("CreateArticle") // Tên endpoint (duy nhất) .RequireAuthorization() // Bắt buộc JWT .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status401Unauthorized) .ProducesValidationProblem() .DisableAntiforgery(); // Chỉ thêm khi dùng [FromForm] return app; } } ``` ### Quy tắc endpoint | Rule | Chi tiết | |---|---| | **Đăng ký** | Phải gọi `Endpoint.Map(app)` trong `Program.cs`, thêm ở cuối section tương ứng | | **Auth required** | Dùng `.RequireAuthorization()` cho mọi endpoint cần đăng nhập | | **Admin only** | Endpoint management phải kiểm tra `user.IsAdminOrManagement()` trong handler | | **Form data** | Dùng `[FromForm]` + `.DisableAntiforgery()` khi upload file | | **JSON body** | Dùng `[FromBody]` cho request JSON thông thường | | **Claims** | Dùng extension methods: `user.GetUserId()`, `user.GetUserName()`, `user.GetUserRole()`, `user.IsAdminOrManagement()` | | **Swagger** | Mỗi endpoint phải có `.WithTags()` và `.WithName()` duy nhất | --- ## 9. Validator (FluentValidation) ```csharp public class CreateArticleValidator : AbstractValidator { public CreateArticleValidator() { RuleFor(x => x.Title).NotEmpty().MaximumLength(200); RuleFor(x => x.Content).NotEmpty(); RuleFor(x => x.Summary).MaximumLength(500).When(x => x.Summary != null); RuleFor(x => x.CoverImageUrl).NotEmpty().WithMessage("Cover image is required"); } } ``` **Quy tắc:** - Validator inherit từ `AbstractValidator`. - Được đăng ký tự động qua `services.AddValidatorsFromAssembly(typeof(Program).Assembly)`. - Không cần tự đăng ký trong DI. - Không validate logic nghiệp vụ phức tạp (ví dụ: check DB) trong Validator – việc đó dành cho Handler. --- ## 10. SharedLibrary – Các kiểu dùng chung Service dùng `SharedLibrary` (project `Library`). Các kiểu quan trọng: | Kiểu | Mô tả | Cách dùng | |---|---|---| | `Responses` | Wrapper cho response | `new Responses(bool flag, string message, T? data)` | | `LogExceptions` | Logging helper | `LogExceptions.LogException(ex)`, `LogExceptions.LogToConsole(msg)` | | `ShareServiceContainer` | DI helper | Đã đăng ký trong `Config_Infrastructure/ServicesContainer.cs` | --- ## 11. SignalR – Thông báo real-time **Hub:** `ArticleNotificationHub` tại `/hubs/articles` **Events (strongly-typed constants):** ```csharp ArticleHubEvents.ArticleCreated // Hub event: bài viết được tạo ArticleHubEvents.ArticleApproved // Hub event: bài viết được duyệt ArticleHubEvents.ArticleRejected // Hub event: bài viết bị từ chối ArticleHubEvents.ArticleFeatured // Hub event: bài viết được featured ``` **Cách phát notify trong Handler:** ```csharp await _hubContext.Clients.All.SendAsync( ArticleHubEvents.ArticleApproved, new ArticleNotification(article.Id, article.Title, ArticleHubEvents.ArticleApproved, DateTime.UtcNow), cancellationToken ); ``` **Quy tắc:** - **Luôn** inject `IHubContext` (không inject `ArticleNotificationHub` trực tiếp). - Chỉ phát notify sau khi `SaveChangesAsync()` thành công. - Các operations cần notify: Create, Approve, Reject, SetFeatured. --- ## 12. File Storage – Upload ảnh **Interface:** `IFileStorage` (`Infrastructure/Abstraction/IFileStorage.cs`) ```csharp public interface IFileStorage { Task SaveOrOverwriteFileAsync(Stream fileStream, string contentType, string relativePath, CancellationToken ct); Task DeleteAsync(string relativePath, CancellationToken ct); } ``` **Triển khai:** `FirebaseStorageService` (Singleton, đã đăng ký trong DI). **Hai luồng upload:** | Luồng | Endpoint | Mô tả | |---|---|---| | **Server-side upload** | `POST /api/image/upload` | Client gửi file lên server → server upload lên Firebase | | **Client-side direct upload** | `POST /api/image/upload-urls` → `PUT {signedUrl}` → `POST /api/image/save-urls` | Server tạo presigned URL → client upload thẳng lên Firebase → client thông báo URL đã lưu | **Quy tắc:** - Nếu upload thất bại hoặc validation lỗi sau khi đã upload file → gọi `fileStorage.DeleteAsync()` để cleanup. - Path lưu cover image: `articles/covers/{Guid}{ext}`. - Path lưu media trong bài viết: `media/{userId}/{Guid}{ext}`. --- ## 13. Slugify – Tạo Slug từ Title Hàm `Slugify()` **phải được dùng nhất quán** khi tạo/update slug cho Article, Tag, Category. ```csharp // Logic chuẩn (trong CreateArticleHandler): // 1. ToLowerInvariant() // 2. Thay "đ" → "d" (Vietnamese) // 3. Normalize FormD → xóa NonSpacingMark // 4. Xóa ký tự không phải [a-z0-9\s-] // 5. Thay spaces/multiple hyphens → single hyphen // 6. Trim hyphens ở đầu/cuối // Đảm bảo slug duy nhất: private async Task EnsureUniqueSlug(string baseSlug, CancellationToken ct) { var slug = baseSlug; var i = 1; while (await _context.Articles.AnyAsync(a => a.Slug == slug, ct)) slug = $"{baseSlug}-{i++}"; return slug; } ``` --- ## 14. Đăng ký DI – Quy trình thêm Handler mới Khi thêm **bất kỳ Handler mới** nào, bắt buộc thực hiện đủ **3 bước**: ### Bước 1: Thêm MediatR registration trong `Config_Infrastructure/ServicesContainer.cs` ```csharp // Thêm vào method AddInfrastructure() services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(YourNewHandler).Assembly) ); ``` ### Bước 2: Đăng ký Endpoint trong `Program.cs` ```csharp // Thêm vào đúng section trong Program.cs YourNewEndpoint.Map(app); ``` ### Bước 3: Tổ chức đúng namespace & thư mục ``` Features/{Domain}/{UseCaseName}/ YourNewCommand.cs YourNewHandler.cs YourNewEndpoint.cs ... ``` > ⚠️ Nếu quên đăng ký MediatR hoặc Endpoint thì handler **sẽ không được gọi** và sẽ gây lỗi runtime. --- ## 15. Quyền truy cập & Authentication - **JWT Authentication** được validate qua `ShareServiceContainer.AddShareServices()`. - **Claim chuẩn:** `uid` = UserId (Guid), `username` = UserName, `email` = Email, `role` = Role. - **Extension methods** (dùng thay vì truy cập claim trực tiếp): ```csharp user.GetUserId() // → Guid? user.GetUserName() // → string? user.GetUserEmail() // → string? user.GetUserRole() // → string? user.IsAdminOrManagement() // → bool (kiểm tra role "Admin" hoặc "Management") ``` - **Public endpoints** (không cần auth): GET articles, GET categories, GET tags, GET comments, GET author profile, GET featured/related articles. - **Authenticated endpoints**: POST/PUT/DELETE cần `.RequireAuthorization()`. - **Admin-only endpoints**: Gọi `user.IsAdminOrManagement()` → trả `Results.Forbid()` nếu không phải admin. --- ## 16. appsettings.json – Cấu hình bắt buộc ```json { "ConnectionStrings": { "BloggingPlatform": "" }, "MySerilog": { "FileName": "ContentApi" }, "Authentication": { "Key": "", "Issuer": "http://localhost:5000", "Audience": "http://localhost:5000" }, "Security": { "GatewaySignature": "Signed" }, "ApiGateway": { "BaseAddress": "http://localhost:5003" }, "Firebase": { "BucketName": ".appspot.com", "CredentialPath": "firebase-credentials.json" } } ``` > Thông tin nhạy cảm (`Key`, `BucketName`, connection string) để trong **User Secrets** hoặc biến môi trường, **không commit lên git**. --- ## 17. Database & Migration - **ORM:** Entity Framework Core 8 - **Provider:** MySQL / SQL Server (qua SharedLibrary config) - **DbContext:** `ContentDbContext` - **Auto-migrate:** Chạy tự động khi startup qua `ContentDataSeeder.SeedAsync()` ```bash # Tạo migration mới dotnet ef migrations add --project ContentAPI # Apply migration thủ công dotnet ef database update --project ContentAPI ``` **Quy tắc:** - Mỗi lần thay đổi Entity, **phải tạo migration mới**. - Không sửa migration đã commit (tạo migration mới thay thế). - Soft-delete: dùng `IsDelete = true`, không xóa rows. --- ## 18. Paging – GetArticles / GetComments Mọi endpoint trả danh sách phải dùng `PagedResponse`: ```csharp public class PagedResponse { public List Items { get; set; } public int Page { get; set; } public int PageSize { get; set; } public int TotalCount { get; set; } public int TotalPages { get; set; } public bool HasNextPage => Page < TotalPages; public bool HasPreviousPage => Page > 1; } ``` **Pattern chuẩn trong Handler:** ```csharp var totalCount = await dbQuery.CountAsync(ct); var totalPages = (int)Math.Ceiling(totalCount / (double)query.PageSize); var skip = (query.Page - 1) * query.PageSize; var items = await dbQuery .OrderByDescending(a => a.PublishedAt) .Skip(skip) .Take(query.PageSize) .ToListAsync(ct); ``` --- ## 19. Article Lifecycle (Vòng đời bài viết) ``` [Tạo mới] IsPublished = false IsRejected = false │ ▼ [Author Publish] POST /api/articles/{id}/publish IsPublished = true PublishedAt = DateTime.UtcNow │ ▼ [Admin Review] ┌──────────────────┬──────────────────────┐ │ Approve │ Reject │ │ IsPublished=true│ IsPublished=false │ │ IsRejected=false│ IsRejected=true │ │ → SignalR notify│ → SignalR notify │ └──────────────────┴──────────────────────┘ │ ▼ [Author Edit] PUT /api/articles/{id} IsRejected = false ← reset upon edit Slug regenerated nếu Title thay đổi │ ▼ [Admin SetFeatured] PUT /api/management/articles/{id}/featured IsFeatured = true → SignalR notify │ ▼ [Soft Delete] DELETE /api/articles/{id} IsDelete = true ``` --- ## 20. Comment System – Nested Replies Comments hỗ trợ 1 cấp nested reply thông qua `ParentCommentId`: ``` Comment A (ParentCommentId = null) ├── Reply B (ParentCommentId = A.Id) └── Reply C (ParentCommentId = A.Id) Comment D (ParentCommentId = null) ``` **Quy tắc:** - Chỉ comment trên bài viết **đã publish và chưa bị xóa** mới được tạo. - Khi reply, phải verify parent comment tồn tại, thuộc cùng article và chưa bị xóa. - Soft-delete comment: `IsDeleted = true`. --- ## 21. Checklist khi thêm Feature mới Thực hiện **đủ tất cả** các bước sau: - [ ] 1. Tạo thư mục `Features/{Domain}/{UseCaseName}/` - [ ] 2. Tạo `*Command.cs` hoặc `*Query.cs` (implement `IRequest>`) - [ ] 3. Tạo `*Request.cs` (nếu cần bind HTTP input) - [ ] 4. Tạo `*Response.cs` (dữ liệu trả về) - [ ] 5. Tạo `*Validator.cs` (inherit `AbstractValidator`) - [ ] 6. Tạo `*Handler.cs` (implement `IRequestHandler>`) - [ ] 7. Tạo `*Endpoint.cs` (static class với method `Map(IEndpointRouteBuilder)`) - [ ] 8. Thêm `services.AddMediatR(...)` trong `Config_Infrastructure/ServicesContainer.cs` - [ ] 9. Gọi `YourEndpoint.Map(app)` trong `Program.cs` - [ ] 10. Nếu thay đổi Entity → tạo Migration mới - [ ] 11. Kiểm tra auth: Public? `.RequireAuthorization()`? Admin-only `user.IsAdminOrManagement()`? - [ ] 12. Nếu cần SignalR → inject `IHubContext` và phát sau SaveChanges --- ## 22. Anti-patterns – Những điều KHÔNG được làm | ❌ Sai | ✅ Đúng | |---|---| | Đặt business logic vào Entity | Đặt logic trong Handler | | Inject `ContentDbContext` trực tiếp vào Endpoint | Dùng MediatR để gọi Handler | | Dùng `context.SaveChanges()` (sync) | Dùng `await context.SaveChangesAsync(ct)` | | Bỏ qua Validator | Luôn validate trước khi gọi Handler | | Throw exception ra ngoài Handler | Catch và return `Responses(false, message)` | | Truy cập claim trực tiếp bằng string key | Dùng extension methods `user.GetUserId()` ... | | Xóa record khỏi DB | Soft-delete: `IsDelete = true` hoặc `IsDeleted = true` | | Viết Endpoint logic dài | Logic nghiệp vụ chỉ trong Handler, Endpoint chỉ map + validate + trả HTTP | | Quên `.DisableAntiforgery()` khi dùng `[FromForm]` | Luôn thêm khi nhận form data | --- ## 23. Chạy Service ```bash # Từ thư mục gốc ContentService cd ContentService/ContentAPI dotnet run # Hoặc từ root solution dotnet run --project ContentService/ContentAPI/ContentAPI.csproj ``` - **Port:** `5002` - **Swagger UI:** `http://localhost:5002/swagger` - **SignalR Hub:** `http://localhost:5002/hubs/articles` - **Static files (uploads):** `http://localhost:5002/uploads/{path}` --- *File này được tạo tự động từ việc phân tích toàn bộ source code của ContentService.* *Cập nhật file này mỗi khi có thay đổi kiến trúc đáng kể.*