Kế hoạch Chi tiết — 3 Feature Ưu tiên
Phạm vi: Thống kê hệ thống · Admin Dashboard · Realtime Availability Grid
Ngày: 2026-05-24
Nguyên tắc: Plan only — không đi vào code implementation
Feature 1 — Thống kê & Báo cáo Hệ thống
1.1 Bài toán & Mục tiêu
Admin cần nhìn thấy sức khỏe toàn hệ thống theo thời gian: tăng trưởng người dùng, hoạt động booking, doanh thu trên toàn platform, phân bố sân theo trạng thái. Data phải hỗ trợ filter theo khoảng thời gian (date range) và hiển thị xu hướng theo ngày.
1.2 Quyết định Kiến trúc
Vấn đề: Data stats nằm phân tán ở 3 service khác nhau (user, field, booking). Cần quyết định ai là người aggregate.
| Lựa chọn | Mô tả | Trade-off |
|---|---|---|
| A — Gateway aggregation | Gateway gọi song song 3 service, merge kết quả | Đơn giản, tái dùng code đã có, không cần infra mới |
| B — Dedicated analytics service | Tạo service riêng thu thập data từ Kafka | Scalable hơn nhưng over-engineering ở quy mô hiện tại |
| C — Booking-service làm hub | Booking-service tổng hợp toàn bộ stats | Phá vỡ domain boundary, coupling cao |
Quyết định: Chọn A — Gateway aggregation
Lý do: Pattern này đã được dùng trong GET /bookings/owner/revenue (gateway gọi field-service lấy fieldIds rồi query booking-service). Áp dụng nhất quán. Mỗi service chỉ expose stats thuộc domain của mình.
1.3 Các API cần tạo mới
Layer 1: Internal stats endpoints (trong từng service)
Cả 3 endpoint này là internal — chỉ được gọi từ gateway qua proxy headers, không expose ra ngoài trực tiếp.
[user-service] — Thêm endpoint mới
GET /users/admin/stats?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
Data cần trả về:
- Tổng số users
- Phân bố theo
role:CUSTOMER,FIELD_OWNER,ADMIN - Phân bố theo
status:ACTIVE,BANNED,SUSPENDED - Số users mới đăng ký trong date range (group by ngày)
Nơi thêm: apps/user-service/src/users/ — thêm method vào UsersService + endpoint vào UsersController
[field-service] — Thêm endpoint mới
GET /fields/admin/stats?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
Data cần trả về:
- Tổng số fields
- Phân bố theo
status:ACTIVE,PENDING_APPROVAL,CLOSED,REJECTED - Số fields mới tạo trong date range (group by ngày)
Nơi thêm: apps/field-service/src/fields/services/fields.service.ts + apps/field-service/src/fields/controllers/
[booking-service] — Thêm endpoint mới
GET /bookings/admin/stats?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
Data cần trả về:
- Tổng số bookings trong date range, phân bố theo
status - Tổng doanh thu (chỉ đếm CONFIRMED + COMPLETED)
- Booking trend: group by ngày (count + revenue/ngày)
- Cancellation rate
Nơi thêm: apps/booking-service/src/bookings/bookings.service.ts — có thể mở rộng từ getOwnerRevenueStats đã có, nhưng scope toàn bộ hệ thống thay vì filter theo fieldIds
Layer 2: Gateway aggregate endpoint (public-facing)
[api-gateway] — Tạo controller mới
GET /admin/stats?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
Authorization: Bearer {token} (Role: ADMIN)
Nơi tạo: apps/api-gateway/src/admin/admin-stats.controller.ts (tạo mới)
Luồng xử lý trong gateway:
1. Validate request (ADMIN role check, date params)
2. Gọi Promise.all([
userProxy.get('/users/admin/stats?...'),
fieldProxy.get('/fields/admin/stats?...'),
bookingProxy.get('/bookings/admin/stats?...')
])
3. Merge 3 kết quả thành 1 response duy nhất
4. Cache kết quả tại gateway (TTL: 5 phút)
1.4 Caching Strategy
Stats là data "chấp nhận được nếu hơi cũ" — không cần realtime. Cache là bắt buộc để tránh query nặng trên DB.
┌─────────────────────────────────────────────┐
│ Cache key: admin:stats:{startDate}:{endDate} │
│ TTL: 5 phút tại gateway │
│ TTL: 10 phút tại mỗi service │
│ │
│ Invalidation: không cần tự động │
│ (user tự refresh bằng cách thay date range) │
└─────────────────────────────────────────────┘
1.5 Checklist triển khai (theo thứ tự)
Step 1 — user-service
□ Thêm method getAdminStats(startDate, endDate) vào UsersService
□ Thêm endpoint GET /users/admin/stats vào UsersController
□ Thêm Redis cache cho endpoint này (TTL: 10 phút)
Step 2 — field-service
□ Thêm method getAdminStats(startDate, endDate) vào FieldsService
□ Thêm endpoint GET /fields/admin/stats vào AdminFieldsController (hoặc controller mới)
□ Thêm Redis cache
Step 3 — booking-service
□ Thêm method getSystemStats(startDate, endDate) vào BookingsService
□ Mở rộng từ getOwnerRevenueStats nhưng không filter fieldIds
□ Thêm endpoint GET /bookings/admin/stats vào BookingsController
□ Thêm Redis cache
Step 4 — api-gateway
□ Tạo apps/api-gateway/src/admin/ folder
□ Tạo AdminStatsController với GET /admin/stats
□ Implement Promise.all aggregation
□ Thêm gateway-level cache (TTL: 5 phút)
□ Register vào app.module.ts
Feature 2 — Tổng quan Admin Dashboard
2.1 Bài toán & Mục tiêu
Màn hình đầu tiên admin thấy sau khi đăng nhập. Mục tiêu: thông báo ngay những việc cần xử lý (pending actions) và cung cấp snapshot nhanh hôm nay + tháng này. Response phải nhanh (< 500ms) vì đây là trang landing.
2.2 Quyết định Kiến trúc
Vấn đề: Dashboard cần data từ nhiều sources, nhưng cần fast. Conflict giữa completeness và latency.
| Lựa chọn | Latency | Độ phức tạp |
|---|---|---|
| A — Gateway gọi parallel, cache ngắn | ~200-300ms (parallel) | Thấp |
| B — Pre-computed background job | ~10ms (read cache) | Cao (cần scheduler) |
| C — Separate dashboard service | ~50ms | Rất cao |
Quyết định: Chọn A + cache ngắn (TTL: 2 phút)
Lý do: Dashboard không cần accurate đến từng giây. Cache 2 phút là chấp nhận được — admin refresh mỗi lần vào trang sẽ thấy data mới nhất trong 2 phút trước.
Insight quan trọng: Không cần tạo endpoint mới ở downstream services. Gateway có thể tái sử dụng các endpoint đã có:
- Count pending owner applications →
GET /owner-applications→ đọctotaltừ response (hoặc thêmcount=trueparam để lightweight hơn) - Count pending fields →
GET /admin/fields/pending?page=1&limit=1→ đọcpagination.total - Today's bookings/revenue →
GET /bookings/admin/stats?startDate=today&endDate=today - User count → có thể lấy từ
GET /admin/stats(đã có ở Feature 1) hoặc call riêng
2.3 API cần tạo mới
Chỉ cần 1 endpoint duy nhất ở gateway — không cần thêm gì ở downstream:
[api-gateway] — Thêm vào AdminStatsController đã tạo ở Feature 1
GET /admin/dashboard
Authorization: Bearer {token} (Role: ADMIN)
Luồng xử lý trong gateway:
1. Xác định ngày hôm nay + đầu tháng này
2. Gọi Promise.all([
userProxy.get('/owner-applications'), → đếm pending
fieldProxy.get('/admin/fields/pending?limit=1'), → đếm pending fields
bookingProxy.get('/bookings/admin/stats?today'), → booking hôm nay
userProxy.get('/users/admin/stats?thisMonth'), → user growth
])
3. Extract và aggregate thành dashboard response
4. Cache tại gateway (TTL: 2 phút)
Response schema:
{
generatedAt: timestamp,
pendingActions: {
ownerApplications: number, // Số đơn đăng ký chờ duyệt
fieldsAwaitingApproval: number, // Số sân chờ phê duyệt
totalActionRequired: number // Tổng
},
today: {
date: YYYY-MM-DD,
newUsers: number,
newBookings: number,
revenue: number, // VNĐ
newFields: number
},
thisMonth: {
totalBookings: number,
totalRevenue: number,
newUsers: number,
cancellationRate: number // 0.0 - 1.0
},
systemHealth: {
totalActiveUsers: number,
totalActiveFields: number,
totalConfirmedBookings: number // all-time
}
}
2.4 Tối ưu Latency
Vì đây là trang landing, latency ảnh hưởng trực tiếp đến UX. Kế hoạch tối ưu:
┌─ Gateway cache (TTL: 2 phút) ──────────────────────┐
│ Cache key: admin:dashboard │
│ Hit rate cao vì nhiều admin cùng xem │
│ Cache miss → gọi parallel → kết quả trong ~300ms │
└────────────────────────────────────────────────────┘
Nếu cache miss:
Promise.all([4 calls]) thay vì sequential
→ Total latency = max(single call latency) ≈ 150-200ms
→ KHÔNG phải sum(4 calls) = 600-800ms
2.5 Checklist triển khai (theo thứ tự)
Step 1 — Phụ thuộc vào Feature 1
□ Phải có GET /bookings/admin/stats trước (để lấy today stats)
□ Phải có GET /users/admin/stats trước (để lấy user growth)
Step 2 — api-gateway
□ Thêm GET /admin/dashboard vào AdminStatsController (đã tạo từ Feature 1)
□ Implement Promise.all với 4 parallel calls
□ Extract pendingActions từ pagination.total của existing endpoints
□ Thêm gateway-level cache (TTL: 2 phút, key: admin:dashboard)
Dependency: Feature 2 phụ thuộc Feature 1 (cần booking/user stats endpoints).
Nên implement Feature 1 trước, Feature 2 sau.
Feature 3 — Realtime Availability Grid
3.1 Bài toán & Mục tiêu
Khi nhiều khách hàng cùng xem lịch trống của một sân phổ biến vào giờ cao điểm, slot có thể bị đặt trong vài giây. FE cần hiển thị lịch luôn phản ánh trạng thái mới nhất — tránh tình huống user thấy slot "AVAILABLE" nhưng khi submit lại bị lỗi 409 Conflict.
3.2 Quyết định Kiến trúc — Polling vs SSE vs WebSocket
Đây là quyết định kỹ thuật quan trọng nhất của feature này. Cần phân tích kỹ:
So sánh 3 phương án
| Tiêu chí | Client Polling | SSE (Server-Sent Events) | WebSocket |
|---|---|---|---|
| Độ phức tạp backend | Thấp (không thay đổi) | Trung bình | Cao |
| Độ phức tạp frontend | Thấp (setInterval) | Thấp (EventSource API) | Trung bình |
| Latency cập nhật | Bằng interval (15-30s) | Gần realtime (< 1s) | Gần realtime (< 1s) |
| Server load | Cao (mọi client đều poll) | Thấp (push khi có event) | Thấp |
| Infra hiện có | Không cần gì thêm | Redis Pub/Sub (đã có) | Cần thêm WS layer |
| Horizontal scale | Đơn giản | Cần Redis Pub/Sub (đã có) | Cần sticky session hoặc Redis |
| HTTP Proxy friendly | ✅ | ✅ (HTTP chunked) | ⚠️ Cần upgrade connection |
| Phù hợp use case này | Chấp nhận được | ✅ Phù hợp nhất | Over-engineering |
Quyết định: SSE + Redis Pub/Sub
Lý do chọn SSE:
-
Booking là event-driven — slot thay đổi khi có booking created/confirmed/cancelled. Đây chính xác là use case SSE được thiết kế cho: server push khi có event, không push khi không có gì thay đổi.
-
Redis đã có trong infra —
libs/cache/đã dùng Redis. Pub/Sub là tính năng native của Redis, không cần thêm infrastructure. -
Kafka events đã tồn tại —
BOOKING_CREATED,BOOKING_CONFIRMED,BOOKING_CANCELLED,BOOKING_EXPIREDđã được publish. Chỉ cần consumer mới để chuyển Kafka event → Redis Pub/Sub → SSE client. -
Tránh poll storm — nếu trang detail sân phổ biến có 100 user cùng xem, polling 15s = 6-7 requests/phút/user × 100 users = 600-700 requests/phút trên 1 sân. SSE: 100 connections dài hạn nhưng server chỉ push khi có thay đổi.
3.3 Kiến trúc Event Flow
┌──────────────┐ booking ┌─────────────────┐
│ Customer │ ─── created ───► │ booking-service │
│ (Bên A) │ └────────┬────────┘
└──────────────┘ │ Kafka publish
│ booking.created
▼
┌─────────────────────┐
│ Kafka Broker │
└──────────┬──────────┘
│ consume
▼
┌───────────────────────┐
│ api-gateway │
│ AvailabilityConsumer │◄── NEW
│ (Kafka consumer) │
└──────────┬────────────┘
│ Redis PUBLISH
│ field:avail:{fieldId}:{date}
▼
┌───────────────────────┐
│ Redis │
│ Pub/Sub │
└──────────┬────────────┘
│ subscribe
▼
┌───────────────────────┐
│ api-gateway │
│ SSE Controller │◄── NEW
│ GET /bookings/fields/ │
│ :fieldId/stream │
└──────────┬────────────┘
│ SSE event push
▼
┌───────────────────────┐
│ Customer Browser │
│ (Bên B, C, D...) │
│ EventSource API │
└───────────────────────┘
3.4 Redis Pub/Sub Channel Design
Channel naming convention:
field:availability:{fieldId}:{date}
Ví dụ: field:availability:b2c3d4e5-...:2026-06-10
Payload khi publish lên channel:
{
"event": "SLOT_UPDATED",
"fieldId": "b2c3d4e5-...",
"date": "2026-06-10",
"trigger": "BOOKING_CREATED",
"bookingId": "a1b2c3d4-..."
}
Payload chỉ là notification (tín hiệu), không chứa toàn bộ grid data. FE nhận event → tự fetch lại
GET /bookings/fields/:fieldId/schedule-grid?date=...để lấy grid mới nhất. Cách này tránh vấn đề stale data trong SSE payload.
3.5 API cần tạo mới
[api-gateway] — Tạo SSE endpoint mới
GET /bookings/fields/:fieldId/availability/stream?date=YYYY-MM-DD
Authorization: Không bắt buộc (public field availability)
Accept: text/event-stream
Response headers:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
SSE event format:
event: slot_updated
data: {"fieldId":"b2c3d4e5-...","date":"2026-06-10","trigger":"BOOKING_CREATED"}
event: heartbeat
data: {"timestamp":"2026-06-10T08:00:30.000Z"}
Events phát ra:
slot_updated— khi có booking thay đổi trên field+date nàyheartbeat— mỗi 30 giây để giữ connection alive (tránh proxy timeout)
[api-gateway] — Thêm Kafka consumer mới (internal)
Không phải HTTP endpoint, là Kafka consumer chạy trong api-gateway:
Consumer group: api-gateway-availability
Topics subscribe:
- booking.created
- booking.confirmed
- booking.cancelled
- booking.expired
Khi nhận event:
1. Extract fieldId + startTime từ event
2. Tính date = startTime.toISOString().slice(0, 10)
3. Redis PUBLISH field:availability:{fieldId}:{date} {payload}
3.6 Handling Edge Cases
| Tình huống | Cách xử lý |
|---|---|
| Client mất kết nối | EventSource tự reconnect (browser-native behavior) |
| Redis Pub/Sub node restart | SSE stream đứt → EventSource reconnect → resubscribe |
| api-gateway có nhiều instance (scale out) | Redis Pub/Sub broadcast tới tất cả instances → mỗi instance push tới subscribers của mình |
| Client xem nhiều ngày (date picker) | Chỉ subscribe 1 stream cho ngày đang xem, khi đổi ngày → close stream cũ, mở stream mới |
| Không có ai xem sân này | Redis channel không có subscriber → Redis tự cleanup, không tốn resource |
| SSE không được support (proxy cũ) | Fallback về polling (FE detect EventSource support, nếu không có → dùng polling) |
3.7 Phân chia Phase triển khai
Feature này có thể chia làm 2 phase để giảm rủi ro:
Phase 1: Efficient Polling (deploy nhanh, low risk)
Thay đổi backend: Thêm ETag header vào GET /bookings/fields/:fieldId/schedule-grid
- Tính ETag = hash của danh sách bookingIds trong grid
- Nếu client gửi
If-None-Match: {etag}và grid không đổi → trả304 Not Modified(không body) - Giảm bandwidth ~90% khi không có thay đổi
FE polling logic:
FE poll GET /schedule-grid mỗi 15 giây
→ Nếu 304 → không update UI
→ Nếu 200 → update grid
Ưu điểm Phase 1: Zero infrastructure change, deploy ngay.
Phase 2: SSE + Redis Pub/Sub (thực sự realtime)
Implement theo kiến trúc ở mục 3.3:
- Tạo SSE endpoint
- Tạo Kafka consumer trong api-gateway
- FE chuyển từ polling sang EventSource
3.8 Checklist triển khai
Phase 1 — Efficient Polling (ưu tiên trước)
□ [booking-service] Thêm ETag header vào GET /bookings/fields/:fieldId/schedule-grid
→ ETag = hash(sorted bookingIds in grid)
→ Hỗ trợ If-None-Match → 304 Not Modified
□ [FE] Implement polling với If-None-Match header, interval 15 giây
□ [FE] Stop polling khi user rời khỏi trang / tab mất focus
Phase 2 — SSE Realtime (sau Phase 1)
□ [api-gateway] Tạo AvailabilityKafkaConsumer
→ Subscribe: booking.created, booking.confirmed, booking.cancelled, booking.expired
→ Action: Redis PUBLISH field:availability:{fieldId}:{date}
□ [api-gateway] Tạo AvailabilityStreamController
→ GET /bookings/fields/:fieldId/availability/stream?date=YYYY-MM-DD
→ Subscribe Redis channel, pipe thành SSE stream
→ Heartbeat mỗi 30s
→ Cleanup subscription khi client disconnect
□ [api-gateway] Register consumer + controller vào module
□ [FE] Thay polling bằng EventSource API
→ Trên event 'slot_updated': gọi lại GET /schedule-grid để lấy data mới
→ Heartbeat timeout: nếu không nhận heartbeat trong 60s → reconnect
→ Fallback: nếu EventSource không support → dùng polling Phase 1
Tóm tắt Dependency Graph
Feature 1 (Stats)
├── user-service: GET /users/admin/stats [độc lập]
├── field-service: GET /fields/admin/stats [độc lập]
├── booking-service: GET /bookings/admin/stats [độc lập]
└── gateway: GET /admin/stats [phụ thuộc 3 endpoint trên]
│
Feature 2 (Dashboard)
└── gateway: GET /admin/dashboard [phụ thuộc Feature 1]
Feature 3 (Realtime Grid)
Phase 1: ETag polling [độc lập — có thể làm song song]
Phase 2: SSE [phụ thuộc Phase 1 done]
Thứ tự implement khuyến nghị:
1. Feature 3 Phase 1 (ETag) — 1 ngày — low risk, deploy ngay
2. Feature 1 Stats — 3-4 ngày — 3 services + 1 gateway controller
3. Feature 2 Dashboard — 1 ngày — tái dùng Feature 1
4. Feature 3 Phase 2 (SSE) — 2-3 ngày — Kafka consumer + SSE endpoint
Phụ lục: Các file cần tạo/chỉnh sửa
Feature 1 — Stats
| Action | File |
|---|---|
| Tạo mới | apps/user-service/src/users/users-stats.service.ts |
| Chỉnh sửa | apps/user-service/src/users/users.controller.ts (thêm endpoint) |
| Tạo mới | apps/field-service/src/fields/services/fields-stats.service.ts |
| Chỉnh sửa | apps/field-service/src/fields/controllers/admin-fields.controller.ts |
| Chỉnh sửa | apps/booking-service/src/bookings/bookings.service.ts |
| Chỉnh sửa | apps/booking-service/src/bookings/bookings.controller.ts |
| Tạo mới | apps/api-gateway/src/admin/admin-stats.controller.ts |
| Chỉnh sửa | apps/api-gateway/src/app.module.ts |
Feature 2 — Dashboard
| Action | File |
|---|---|
| Chỉnh sửa | apps/api-gateway/src/admin/admin-stats.controller.ts (thêm method) |
Feature 3 — Realtime
| Action | File |
|---|---|
| Chỉnh sửa | apps/booking-service/src/bookings/ (thêm ETag logic vào schedule-grid) |
| Tạo mới | apps/api-gateway/src/availability/availability-kafka.consumer.ts |
| Tạo mới | apps/api-gateway/src/availability/availability-stream.controller.ts |
| Tạo mới | apps/api-gateway/src/availability/availability.module.ts |
| Chỉnh sửa | apps/api-gateway/src/app.module.ts |