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. Slugphải unique và được tạo tự động từTitlequa hàmSlugify().- Khi xóa mềm (soft-delete), set
IsDelete = true, không xóa khỏi DB. AuthorIdlà 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<TCommand>.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<ArticleNotificationHub>.Clients.All.SendAsync()
│ ► Wrap kết quả trong Responses<T> (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<Responses<TResponse>>
CreateArticleRequest.cs # record – bind từ HTTP (FormData hoặc JSON body)
CreateArticleResponse.cs # record – dữ liệu trả về
CreateArticleValidator.cs # FluentValidation AbstractValidator<TCommand>
CreateArticleHandler.cs # IRequestHandler<TCommand, Responses<TResponse>>
CreateArticleEndpoint.cs # static class, method Map(IEndpointRouteBuilder)
4.2 Với Query (Read operation: GET)
Features/Articles/GetArticles/
GetArticlesQuery.cs # record – implements IRequest<Responses<TResponse>>
GetArticlesHandler.cs # IRequestHandler<TQuery, Responses<TResponse>>
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)
// Luôn dùng sealed record
public sealed record CreateArticleCommand(
Guid Id,
Guid AuthorId,
string Title,
string Content,
string? Summary,
Guid? CategoryId,
List<string>? Tags,
string? CoverImageUrl
) : IRequest<Responses<CreateArticleResponse>>;
Query (đọc dữ liệu)
// 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<Responses<PagedResponse<ArticleListItemDto>>>;
Quy tắc:
- Command và Query đều implement
IRequest<TResponse>từ MediatR. - Response luôn wrap trong
Responses<T>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
public class CreateArticleHandler
: IRequestHandler<CreateArticleCommand, Responses<CreateArticleResponse>>
{
private readonly ContentDbContext _context;
private readonly IHubContext<ArticleNotificationHub> _hubContext; // Chỉ khi cần notify
public CreateArticleHandler(ContentDbContext context, IHubContext<ArticleNotificationHub> hubContext)
{
_context = context;
_hubContext = hubContext;
}
public async Task<Responses<CreateArticleResponse>> Handle(
CreateArticleCommand command, CancellationToken ct)
{
try
{
// 1. Business logic
// 2. _context.SaveChangesAsync(ct)
// 3. Broadcast SignalR nếu cần
// 4. Return new Responses<T>(true, "message", data)
}
catch (Exception ex)
{
LogExceptions.LogException(ex);
return new Responses<CreateArticleResponse>(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ởcatchvà log quaLogExceptions.LogException(ex)(từ SharedLibrary). - Trả
Responses<T>(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
public static class CreateArticleEndpoint
{
public static IEndpointRouteBuilder Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/articles",
async ([FromForm] CreateArticleRequest request,
ClaimsPrincipal user,
IValidator<CreateArticleCommand> 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<CreateArticleResponse>(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)
public class CreateArticleValidator : AbstractValidator<CreateArticleCommand>
{
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<TCommand>. - Đượ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<T> | Wrapper cho response | new Responses<T>(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):
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:
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<ArticleNotificationHub>(không injectArticleNotificationHubtrự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)
public interface IFileStorage
{
Task<string> 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.
// 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<string> 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
// Thêm vào method AddInfrastructure()
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(YourNewHandler).Assembly)
);
Bước 2: Đăng ký Endpoint trong Program.cs
// 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<ContentDbContext>(). - 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):
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
{
"ConnectionStrings": {
"BloggingPlatform": "<MySQL/SqlServer connection string>"
},
"MySerilog": { "FileName": "ContentApi" },
"Authentication": {
"Key": "<JWT signing key – phải khớp với IdentityService>",
"Issuer": "http://localhost:5000",
"Audience": "http://localhost:5000"
},
"Security": {
"GatewaySignature": "Signed"
},
"ApiGateway": {
"BaseAddress": "http://localhost:5003"
},
"Firebase": {
"BucketName": "<your-project-id>.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()
# Tạo migration mới
dotnet ef migrations add <TênMigration> --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<T>:
public class PagedResponse<T>
{
public List<T> 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:
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.cshoặc*Query.cs(implementIRequest<Responses<T>>) - 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(inheritAbstractValidator<TCommand>) - 6. Tạo
*Handler.cs(implementIRequestHandler<TCommand, Responses<T>>) - 7. Tạo
*Endpoint.cs(static class với methodMap(IEndpointRouteBuilder)) - 8. Thêm
services.AddMediatR(...)trongConfig_Infrastructure/ServicesContainer.cs - 9. Gọi
YourEndpoint.Map(app)trongProgram.cs - 10. Nếu thay đổi Entity → tạo Migration mới
- 11. Kiểm tra auth: Public?
.RequireAuthorization()? Admin-onlyuser.IsAdminOrManagement()? - 12. Nếu cần SignalR → inject
IHubContext<ArticleNotificationHub>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<T>(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
# 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ể.