0

#11 Bước 7 — Module `post`: bài kiểm tra thật của kiến trúc Phần 1

Tài liệu thực hành. Đây là lần đầu bạn thêm một module mà không sửa một dòng nào trong platform/. Nếu điều đó xảy ra được, kiến trúc đứng vững. Nếu không, bạn vừa tìm thấy chỗ nó rò.

Kiến trúc tổng thể: ARCHITECTURE.md — bước này là hiện thực của §14 Checklist thêm module mới


Vì sao bước này quan trọng hơn nó trông

Sáu bài trước dựng hạ tầng. Bài này dùng hạ tầng đó, và đó là hai việc rất khác nhau.

Cụ thể, ba điều được kiểm chứng lần đầu:

Điều được kiểm chứng Trước bước này Sau bước này
Guard có cứu được số liệu sai không Chưa biết. EnsureUserStatsON CONFLICT DO NOTHING, tự an toàn (bước 6 §5.3) posts_count = posts_count + 1 không tự an toàn. Giao trùng một lần là một con số sai
Thêm module có phải sửa hạ tầng không Chỉ có một module thật (identity) Hai module, và platform/ không được đổi một dòng
Thêm consumer có làm gãy consumer cũ không stats chỉ nghe một event stats nghe bốn event trên hai topic

Nói cách khác: từ bước 1 đến 6, mọi lời hứa của kiến trúc đều là lời hứa. Bước này biến chúng thành sự thật kiểm chứng được — hoặc phơi ra chỗ chúng sai.

Và bước này cũng là nơi hai bài toán rất Việt Nam xuất hiện: tạo slug từ tiêu đề có dấu (§6.2 — có một cạm bẫy mà mọi thư viện slug viết bởi người nói tiếng Anh đều bỏ sót) và đếm thời gian đọc (§6.3).


Mục tiêu

  • Migration 000006_create_posts — bảng cuối cùng của giai đoạn này
  • post/domainSlugify xử lý đúng tiếng Việt, ReadTime, validate
  • post/service — tạo / xoá / đọc / liệt kê, mỗi thao tác ghi phát một event
  • post/transport/http — có xác thực, phân trang keyset
  • stats nghe thêm ba event: OnPostCreated, OnPostDeleted, OnPostViewed
  • Mở lại RecalculatePostsCount đã comment từ bước 3
  • Bài diễn tập chứng minh guard cứu bạn khỏi một con số sai

Mục lục

  1. Phạm vi bước này
  2. Nhìn từ trên xuống
  3. Migration 000006 — posts
  4. post/repository/query.sql
  5. contracts/v1/post_events.go — đọc lại bằng mắt mới
  6. post/domain
  7. post/repository
  8. post/service
  9. post/transport/http
  10. post/module.go và lắp vào cmd/api
  11. stats — nơi guard cuối cùng có việc làm
  12. Mở lại RecalculatePostsCount
  13. ⚠️ total_post_views là ngoại lệ — và vì sao
  14. Chạy thử đầu-cuối
  15. Ba bài diễn tập sự cố
  16. Kiểm chứng bằng test
  17. Những thứ cố ý chưa có
  18. Checklist hoàn thành

1. Phạm vi bước này

1.1 Cái sẽ viết

db/migrations/
  000006_create_posts.up.sql                  ← MỚI
  000006_create_posts.down.sql                 ← MỚI

internal/
  contracts/v1/post_events.go                  ← SỬA (thêm chú thích, không đổi struct)
  modules/
    post/                                      ← MỚI, trọn module
      module.go
      domain/
        post.go
        slug.go
        slug_test.go
        errors.go
      repository/
        query.sql
        postgres.go
        gen/
      service/
        service.go
        create.go
        delete.go
        read.go
        service_test.go
      transport/http/
        dto.go
        handler.go
        router.go
    stats/
      repository/query.sql                     ← SỬA (mở lại RecalculatePostsCount)
      subscriber/
        on_post_created.go                     ← MỚI
        on_post_deleted.go                     ← MỚI
        on_post_viewed.go                      ← MỚI
      module.go                                ← SỬA (đăng ký topic post)
cmd/api/main.go                                ← SỬA (thêm một dòng)

1.2 Cái không được sửa

Đây là phần quan trọng nhất của mục này, và là tiêu chí đánh giá cả bước:

internal/platform/          ← KHÔNG một dòng nào
internal/contracts/envelope.go
internal/modules/identity/  ← KHÔNG một dòng nào
cmd/relay/, cmd/worker/     ← KHÔNG một dòng nào

Nếu trong lúc làm bạn thấy mình phải sửa một file trong platform/, hãy dừng lại và đọc lý do. Có hai khả năng, và chúng đòi hỏi hai phản ứng ngược nhau:

Bạn đang thêm gì vào platform/ Nghĩa là
Một tiện ích kỹ thuật thật (ví dụ helper phân trang) ✅ Bình thường. Phép thử §4.5: nó có dùng được cho một dự án khác không?
Bất cứ gì biết đến chữ "post" ❌ Nghiệp vụ đang rò vào hạ tầng. Đưa nó về modules/post/

Cuối bước có hai lệnh go list -deps để kiểm điều này bằng trình biên dịch (§14.6).


2. Nhìn từ trên xuống

┌─ cmd/api ─────────────────────────────────────────────────────────────┐
│                                                                       │
│  POST /posts            (cần Bearer token)                            │
│    └─ service.Create                                                  │
│         ├─ Slugify(title) + ReadTime(body)     ← NGOÀI transaction    │
│         └─ txm.Do:  INSERT posts                                      │
│                     INSERT outbox (post.post.created.v1)              │
│              COMMIT                        ← ★ một transaction        │
│                                                                       │
│  DELETE /posts/{id}     (cần token, chỉ tác giả)                      │
│    └─ txm.Do:  DELETE posts RETURNING *                               │
│                INSERT outbox (post.post.deleted.v1)                   │
│                                                                       │
│  GET  /posts            keyset pagination, không ghi gì               │
│  GET  /posts/{slug}     không ghi gì                                  │
│  POST /posts/{id}/view  → outbox (post.post.viewed.v1)   ← §13        │
└───────────────────────────────────────────────────────────────────────┘
                              │ outbox
┌─ cmd/relay ───────────────────────────────────────────────────────────┐
│  poll → Kafka topic "post"       ← KHÔNG SỬA GÌ. Topic lấy từ cột.    │
└───────────────────────────────────────────────────────────────────────┘
                              │ Kafka topic "post"
┌─ cmd/worker ──────────────────────────────────────────────────────────┐
│  group "stats-service" nay nghe HAI topic: identity + post            │
│                                                                       │
│  post.post.created.v1 → guard → posts_count + 1        ★ CẦN guard   │
│  post.post.deleted.v1 → guard → posts_count - 1        ★ CẦN guard   │
│  post.post.viewed.v1  →         total_post_views + 1   ⚠️ KHÔNG guard │
└───────────────────────────────────────────────────────────────────────┘

Hai điều đáng chú ý ngay từ sơ đồ:

cmd/relay không sửa một dòng nào. Nó đọc cột topic của bảng outbox và publish vào đúng đó. Thêm topic thứ hai là chuyện của người ghi, không phải người chuyển. Đây là lợi tức của quyết định ở bước 3 §5 khi đưa topic thành một cột thay vì suy ra từ event_type.

Dòng post.post.viewed.v1 không có guard, và sự bất đối xứng đó là cố ý. Lý do đầy đủ ở §13 — đây là chỗ tài liệu này nói rằng không phải event nào cũng xứng đáng cùng một bộ máy.


3. Migration 000006 — posts

db/migrations/000006_create_posts.up.sql:

CREATE TABLE posts (
    id         UUID         PRIMARY KEY DEFAULT gen_random_uuid(),

    -- ON DELETE CASCADE: xoá user thì bài viết biến mất theo. Xem §3.3 —
    -- quyết định này có một hệ quả về event cần biết trước.
    author_id  UUID         NOT NULL REFERENCES users(id) ON DELETE CASCADE,

    title      VARCHAR(255) NOT NULL,

    -- 255 = 246 ký tự phần tiêu đề + 1 gạch + 8 ký tự hậu tố id (§6.2).
    slug       VARCHAR(255) NOT NULL,

    body       TEXT         NOT NULL,

    -- Tính sẵn lúc ghi, không tính lúc đọc. Xem §6.3.
    read_time  INT          NOT NULL DEFAULT 1,

    created_at TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ  NOT NULL DEFAULT now(),

    CONSTRAINT posts_slug_key UNIQUE (slug),

    -- read_time = 0 sẽ hiển thị "0 phút đọc" — vô nghĩa với người đọc.
    CONSTRAINT posts_read_time_positive CHECK (read_time >= 1),

    -- Lưới an toàn cho tầng service: NOT NULL không chặn được chuỗi rỗng
    -- hay chuỗi chỉ có khoảng trắng.
    CONSTRAINT posts_title_not_blank CHECK (btrim(title) <> ''),
    CONSTRAINT posts_body_not_blank  CHECK (btrim(body)  <> '')
);

-- Index cho phân trang keyset toàn hệ thống (Phụ lục A).
-- Thứ tự cột PHẢI khớp ORDER BY của query, kể cả chiều DESC.
CREATE INDEX idx_posts_created ON posts (created_at DESC, id DESC);

-- Index cho "bài viết của một tác giả". author_id đứng trước vì nó là
-- cột được lọc bằng dấu bằng; hai cột sau phục vụ sắp xếp.
CREATE INDEX idx_posts_author_created ON posts (author_id, created_at DESC, id DESC);

COMMENT ON TABLE posts IS
    'Nguồn sự thật của bài viết. user_stats.posts_count là bản sao tính sẵn từ đây.';
COMMENT ON COLUMN posts.slug IS
    'kebab-case của tiêu đề + hậu tố 8 ký tự từ id. Sinh ở tầng service, xem docs/07 §6.2.';

db/migrations/000006_create_posts.down.sql:

DROP TABLE IF EXISTS posts;

Chạy:

migrate -path db/migrations -database "postgres://app:secret@localhost:5432/community?sslmode=disable" up
docker compose -f deployments/docker-compose.yml exec postgres psql -U app -d community -c "\d posts"

3.1 Vì sao slug cần UNIQUE dù §6.2 đã đảm bảo không trùng

Hậu tố 8 ký tự từ UUID khiến hai bài cùng tiêu đề vẫn có slug khác nhau, nên về lý thuyết UNIQUE không bao giờ bị chạm tới.

Đó chính là lý do nên có nó.

Ràng buộc UNIQUE ở đây là lưới an toàn cho một sai lầm trong tương lai: ngày ai đó "làm URL đẹp hơn" bằng cách bỏ hậu tố, ràng buộc này biến một lỗi âm thầm (hai bài viết cùng URL, một cái không bao giờ mở được) thành một lỗi ồn ào ngay lần chạy test đầu tiên.

Cùng lý lẽ với CHECK (posts_count >= 0)bước 3 §4.2: lớp trong query xử lý luồng bình thường, lớp trong schema bắt lỗi lập trình.

3.2 Hai index, và vì sao chiều DESC quan trọng

CREATE INDEX idx_posts_created ON posts (created_at DESC, id DESC);

PostgreSQL đọc được index theo cả hai chiều, nên với một cột thì DESC không cần thiết. Nhưng với nhiều cột thì chiều tương đối giữa chúng mới là điều quan trọng:

Index ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id ASC
(created_at DESC, id DESC) ✅ quét thẳng ❌ phải sắp xếp lại
(created_at, id) ✅ quét ngược ❌ phải sắp xếp lại

Query phân trang ở §4 dùng ORDER BY created_at DESC, id DESC, nên index phải khớp cả hai cột cùng chiều đó. Lệch một cột là Sort node xuất hiện trong EXPLAIN và mọi lợi ích của keyset pagination biến mất.

Kiểm chứng khi có dữ liệu:

EXPLAIN ANALYZE SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20;

Phải thấy Index Scan using idx_posts_created, không thấy Sort.

3.3 ⚠️ ON DELETE CASCADE và event không bao giờ được phát

Xoá một user sẽ xoá hết bài viết của họ — ở tầng database, im lặng, không qua Go.

Nghĩa là không có event post.post.deleted.v1 nào được phát cho những bài viết đó. Nếu stats dựa vào event để giảm posts_count, con số sẽ sai.

Ở đây nó tình cờ đúng, vì user_stats cũng có ON DELETE CASCADE từ users (bước 3 §4): user đi thì cả bài viết và dòng thống kê cùng đi. Không còn ai để mà sai.

Nhưng "tình cờ đúng" là trạng thái đáng ghi lại, vì nó sẽ hết đúng. Ngày bạn thêm một read model không cascade từ users — ví dụ bảng tag_stats đếm số bài theo tag — thì việc xoá user sẽ để lại số liệu lệch mà không event nào giải thích được.

Quy tắc: ON DELETE CASCADE là một đường ghi dữ liệu không phát event. Mỗi lần dùng nó, hãy tự hỏi có read model nào đang đếm thứ vừa bị xoá. Nếu có, hoặc cho read model đó cascade theo, hoặc đừng cascade mà xoá tường minh trong service để còn phát được event.


4. post/repository/query.sql

New-Item -ItemType Directory -Force internal/modules/post/repository

internal/modules/post/repository/query.sql:

-- name: CreatePost :one
-- id do service sinh trước, vì cần nó để dựng slug VÀ dựng event
-- trong cùng transaction (cùng lý lẽ với CreateUser, bước 3 §3.1).
INSERT INTO posts (id, author_id, title, slug, body, read_time)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;

-- name: GetPostByID :one
SELECT * FROM posts WHERE id = $1;

-- name: GetPostBySlug :one
SELECT * FROM posts WHERE slug = $1;

-- name: DeletePostByAuthor :one
-- RETURNING để lấy author_id dựng event, và để phân biệt "xoá được"
-- với "không có gì để xoá" bằng pgx.ErrNoRows.
--
-- Điều kiện author_id nằm TRONG câu lệnh, không phải một lần kiểm tra
-- riêng trước đó — xem §8.3.
DELETE FROM posts WHERE id = $1 AND author_id = $2
RETURNING *;

-- ─── Phân trang keyset (Phụ lục A) ──────────────────────────────
-- Hai query thay vì một, vì so sánh hàng (row comparison) với NULL
-- không diễn đạt gọn được trong sqlc. Xem ghi chú dưới.

-- name: ListPostsFirstPage :many
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1;

-- name: ListPostsAfter :many
-- So sánh HÀNG, không phải hai điều kiện OR. Đây là điểm mấu chốt —
-- xem Phụ lục A.
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;

-- name: ListPostsByAuthorFirstPage :many
SELECT * FROM posts
WHERE author_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2;

-- name: ListPostsByAuthorAfter :many
SELECT * FROM posts
WHERE author_id = $1 AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT $4;

-- name: CountPostsByAuthor :one
-- Dùng cho RecalculatePostsCount và cho test đối chiếu (§12).
SELECT count(*) FROM posts WHERE author_id = $1;

Vì sao bốn query liệt kê thay vì một query "thông minh"

Cách gộp trông rất hấp dẫn:

-- ❌ Gọn hơn, nhưng mua thêm hai vấn đề
SELECT * FROM posts
WHERE (sqlc.narg(author_id)::uuid IS NULL OR author_id = sqlc.narg(author_id))
  AND (sqlc.narg(before_at)::timestamptz IS NULL
       OR (created_at, id) < (sqlc.narg(before_at), sqlc.narg(before_id)))
ORDER BY created_at DESC, id DESC LIMIT $1;

Hai vấn đề, và cái thứ hai nặng hơn:

  1. Chỗ gọi mất kiểm tra lúc biên dịch. Với bốn hàm riêng, quên truyền con trỏ là lỗi biên dịch. Với một hàm nhận toàn con trỏ nil được, quên là lỗi lúc chạy.
  2. PostgreSQL khó dùng index cho IS NULL OR .... Planner phải chọn một kế hoạch chạy được cho mọi tổ hợp tham số, nên nó thường chọn Seq Scan. Với bảng 10 dòng thì không thấy; với 10 triệu dòng thì đây là khác biệt giữa 2ms và 4 giây.

Bốn query dài dòng hơn nhưng mỗi query có đúng một kế hoạch tối ưu. Đây cũng là lý lẽ đã dùng ở bước 3 §9.5 khi chọn tám query counter riêng thay vì một query động.

Cập nhật sqlc.yaml — thêm khối thứ năm, cùng khuôn với bốn khối đã có ở bước 3 §8:

  - engine: "postgresql"
    schema: "db/migrations"
    queries: "internal/modules/post/repository/query.sql"
    gen:
      go:
        package: "gen"
        out: "internal/modules/post/repository/gen"
        sql_package: "pgx/v5"
        overrides: *pgtypes
sqlc generate

5. contracts/v1/post_events.go — đọc lại bằng mắt mới

File này đã viết ở bước 2 §5.4, trước khi có bảng posts. Giờ là lúc kiểm lại xem nó có đúng không — và nó đúng, nhưng vì một lý do mà lúc đó chưa nói rõ được.

package v1

import "time"

const (
	TypePostCreated = "post.post.created.v1"
	TypePostViewed  = "post.post.viewed.v1"
	TypePostDeleted = "post.post.deleted.v1"
)

type PostCreated struct {
	PostID    string    `json:"post_id"`
	AuthorID  string    `json:"author_id"`
	Title     string    `json:"title"`
	Slug      string    `json:"slug"`
	ReadTime  int       `json:"read_time"`
	CreatedAt time.Time `json:"created_at"`
}

type PostViewed struct {
	PostID string `json:"post_id"`
	// AuthorID có mặt ở đây để consumer stats cập nhật total_post_views
	// của tác giả mà KHÔNG phải query ngược sang bảng posts.
	AuthorID string `json:"author_id"`
	ViewerID string `json:"viewer_id"`
}

type PostDeleted struct {
	PostID   string `json:"post_id"`
	AuthorID string `json:"author_id"`
}

5.1 ★ Vì sao PostDeleted phải mang AuthorID

Trường này trông thừa: đã có PostID thì tra bảng posts là ra author_id. Ở bước 2 tôi chỉ ghi "trùng lặp dữ liệu là cái giá rẻ để giữ module độc lập" — đúng nhưng chưa đủ mạnh. Lý do thật cụ thể hơn nhiều:

Tới lúc consumer chạy, dòng posts đã không còn tồn tại.

t=0.00s  cmd/api:    DELETE FROM posts WHERE id = X     ┐ một transaction
                     INSERT INTO outbox (...)           ┘
t=0.00s              COMMIT              ← dòng posts BIẾN MẤT tại đây
t=0.30s  cmd/relay:  đọc outbox → Kafka
t=0.35s  cmd/worker: OnPostDeleted(post_id = X)
                     SELECT author_id FROM posts WHERE id = X
                     → 0 dòng. Không có cách nào biết giảm counter của ai.

Không có AuthorID trong payload, handler này không thể làm việc của nó, và không có cách sửa nào ngoài đổi hợp đồng event — tức là tạo v2, chạy song song hai phiên bản, chuyển từng consumer (quy tắc versioning, bước 2 §5.4). Một trường bị thiếu trở thành nhiều tuần công việc.

Đây là nguyên tắc thiết kế event quan trọng nhất của cả tài liệu, nên tôi phát biểu tường minh:

Event phải tự chứa đủ dữ liệu để consumer làm việc mà không cần tra lại nguồn — vì tới lúc nó chạy, nguồn có thể đã đổi hoặc đã biến mất.

Kiểm tra nhanh cho mọi event bạn thiết kế sau này: "nếu consumer xử lý event này sau đúng một giờ, nó còn làm được việc không?"

  • PostDeleted → dòng đã bị xoá. Phải tự chứa.
  • PostCreated → bài viết có thể đã sửa tiêu đề. Payload phải mang tiêu đề lúc tạo, không phải tiêu đề hiện tại.
  • UserRegistered → user vẫn còn, nhưng có thể đã đổi tên. Cùng lý lẽ.

5.2 Và vì sao PostCreated không mang Body

Đối xứng với mục trên nhưng ngược chiều — không phải trường nào cũng nên có mặt.

Lý do bỏ Body Con số cụ thể
Message phình to Bài viết 50 KB × 1.000 bài = 50 MB đi qua outbox, Kafka, log của mọi consumer, và DLQ
Không ai cần stats chỉ cộng 1 vào counter. Nó không quan tâm nội dung
Vòng đời rất dài Payload event sống trong Kafka 7 ngày và trong log lâu hơn thế (bước 4 §10.1)

Ngày có consumer thật cần Body — một search indexer — bạn đứng trước một ngã ba thật, và Phụ lục B bàn cả hai nhánh. Điều cần nhớ bây giờ: đưa Body vào payload sớm là trả giá cho mọi consumer, kể cả những consumer không cần nó.


6. post/domain

6.1 post.goerrors.go

internal/modules/post/domain/errors.go:

package domain

import "errors"

var (
	ErrInvalidInput = errors.New("dữ liệu không hợp lệ")
	ErrPostNotFound = errors.New("không tìm thấy bài viết")
)

Không có ErrNotYourPost. Cố ý — xem §8.3.

internal/modules/post/domain/post.go:

// Package domain chứa entity và quy tắc nghiệp vụ của module post.
// Không tag json, không tag db, không import gì ngoài thư viện chuẩn
// và uuid — cùng khuôn với identity/domain (bước 4 §11).
package domain

import (
	"fmt"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/google/uuid"
)

const (
	MaxTitleLen = 255
	MinTitleLen = 3

	// MaxBodyLen là giới hạn nghiệp vụ. Giới hạn kỹ thuật ở tầng HTTP là
	// http.MaxBytesReader (bước 4 §6.2) và nó phải LỚN HƠN con số này,
	// nếu không người dùng nhận 413 thay vì một thông báo dễ hiểu.
	MaxBodyLen = 100_000
)

type Post struct {
	ID        uuid.UUID
	AuthorID  uuid.UUID
	Title     string
	Slug      string
	Body      string
	ReadTime  int
	CreatedAt time.Time
	UpdatedAt time.Time
}

// Draft là dữ liệu thô từ client, chưa qua kiểm tra.
type Draft struct {
	AuthorID uuid.UUID
	Title    string
	Body     string
}

// Validate kiểm tra theo BYTE, không theo ký tự.
//
// Cột title là VARCHAR(255) — PostgreSQL đếm KÝ TỰ, nên 255 ký tự tiếng
// Việt vừa khít cột nhưng chiếm tới ~500 byte. Ta kiểm bằng ký tự để
// khớp với cột, và đó là lý do dùng utf8.RuneCountInString chứ không len().
func (d Draft) Validate() error {
	title := strings.TrimSpace(d.Title)
	body := strings.TrimSpace(d.Body)

	switch {
	case d.AuthorID == uuid.Nil:
		return fmt.Errorf("%w: thiếu tác giả", ErrInvalidInput)

	case utf8.RuneCountInString(title) < MinTitleLen:
		return fmt.Errorf("%w: tiêu đề phải có ít nhất %d ký tự", ErrInvalidInput, MinTitleLen)

	case utf8.RuneCountInString(title) > MaxTitleLen:
		return fmt.Errorf("%w: tiêu đề tối đa %d ký tự", ErrInvalidInput, MaxTitleLen)

	case body == "":
		return fmt.Errorf("%w: nội dung không được rỗng", ErrInvalidInput)

	case utf8.RuneCountInString(body) > MaxBodyLen:
		return fmt.Errorf("%w: nội dung tối đa %d ký tự", ErrInvalidInput, MaxBodyLen)
	}
	return nil
}

// NewPost dựng entity đã chuẩn hoá, sẵn sàng để ghi.
//
// ID được sinh Ở ĐÂY chứ không để DB tự sinh, vì slug cần id (§6.2) và
// event cần id — cả hai đều phải có trước khi INSERT chạy.
func NewPost(d Draft) Post {
	id := uuid.New()
	title := strings.TrimSpace(d.Title)
	body := strings.TrimSpace(d.Body)

	return Post{
		ID:       id,
		AuthorID: d.AuthorID,
		Title:    title,
		Slug:     Slugify(title, id),
		Body:     body,
		ReadTime: ReadTime(body),
	}
}

TrimSpace xong mới kiểm, và kiểm cái đã trim. Nếu kiểm d.Title thô rồi mới trim khi ghi, một tiêu đề " " sẽ qua được MinTitleLen (3 ký tự khoảng trắng) rồi trở thành chuỗi rỗng trong database — nơi CHECK (btrim(title) <> '') chặn lại bằng một lỗi 500 khó hiểu. Trim trước, kiểm sau.

6.2 ★ Slugify — cạm bẫy tiếng Việt

Đây là phần tôi khuyên đọc kỹ nhất trong bài, vì lỗi ở đây im lặng và mọi thư viện slug phổ biến đều mắc.

internal/modules/post/domain/slug.go:

package domain

import (
	"strings"
	"unicode"
	"unicode/utf8"

	"github.com/google/uuid"
	"golang.org/x/text/runes"
	"golang.org/x/text/transform"
	"golang.org/x/text/unicode/norm"
)

const (
	// slugBodyMax + 1 gạch + slugSuffixLen = 255, khớp VARCHAR(255).
	slugBodyMax   = 246
	slugSuffixLen = 8
)

// vietnameseSpecial xử lý các ký tự KHÔNG phân rã được bằng NFD.
//
// ⚠️ ĐÂY LÀ DÒNG QUAN TRỌNG NHẤT CỦA FILE. Xem giải thích bên dưới —
// thiếu nó thì "Đường đến với Golang" thành "uong-en-voi-golang".
var vietnameseSpecial = strings.NewReplacer(
	"đ", "d", "Đ", "d",
	// Tiện tay xử lý luôn vài ký tự hay gặp cũng không phân rã được:
	"ø", "o", "Ø", "o",
	"æ", "ae", "Æ", "ae",
	"ß", "ss",
)

// Slugify tạo slug từ tiêu đề, kèm hậu tố từ id để không bao giờ trùng.
//
//	Slugify("Xin chào Việt Nam", id) → "xin-chao-viet-nam-3f2a1b8c"
func Slugify(title string, id uuid.UUID) string {
	return slugBody(title) + "-" + id.String()[:slugSuffixLen]
}

func slugBody(title string) string {
	// BƯỚC 1 — thay các ký tự không phân rã được. PHẢI làm trước bước 2.
	s := vietnameseSpecial.Replace(title)

	// BƯỚC 2 — NFD tách "ế" thành "e" + hai dấu, rồi xoá mọi dấu.
	// unicode.Mn = "Mark, nonspacing", đúng lớp chứa dấu thanh và dấu mũ.
	t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
	folded, _, err := transform.String(t, s)
	if err != nil {
		folded = s // không bao giờ xảy ra với chuỗi UTF-8 hợp lệ, nhưng đừng panic
	}

	// BƯỚC 3 — chỉ giữ [a-z0-9], mọi thứ khác thành một gạch nối duy nhất.
	var b strings.Builder
	b.Grow(len(folded))
	lastHyphen := true // true để không mở đầu bằng gạch
	for _, r := range strings.ToLower(folded) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			b.WriteRune(r)
			lastHyphen = false
		default:
			if !lastHyphen {
				b.WriteByte('-')
				lastHyphen = true
			}
		}
	}
	out := strings.Trim(b.String(), "-")

	// BƯỚC 4 — cắt độ dài, KHÔNG cắt giữa một từ.
	if len(out) > slugBodyMax {
		out = out[:slugBodyMax]
		if i := strings.LastIndexByte(out, '-'); i > 0 {
			out = out[:i]
		}
		out = strings.Trim(out, "-")
	}

	// BƯỚC 5 — tiêu đề toàn emoji / chữ Hán / dấu câu cho ra chuỗi rỗng.
	// Không có bước này, slug thành "-3f2a1b8c" — URL hỏng và trông như bug.
	if out == "" || !utf8.ValidString(out) {
		return "bai-viet"
	}
	return out
}

// ReadTime trả về số phút đọc, tối thiểu 1.
func ReadTime(body string) int {
	const wordsPerMinute = 200

	words := len(strings.Fields(body))
	// Chia LÀM TRÒN LÊN: 201 từ là 2 phút, không phải 1.
	minutes := (words + wordsPerMinute - 1) / wordsPerMinute
	if minutes < 1 {
		return 1
	}
	return minutes
}

6.2.1 ⚠️ Vì sao đ phá vỡ cách làm thông thường

Công thức bỏ dấu tiếng Việt mà bạn tìm thấy ở mọi nơi là:

transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)

Nó hoạt động bằng cách phân rã một ký tự có dấu thành ký tự gốc cộng các dấu, rồi xoá dấu. Kiểm chứng bằng số byte thật:

Ký tự NFD ra Số rune Byte
ế e + ◌̂ + ◌́ 3 65 cc 82 cc 81
u + ◌̛ + ◌̃ 3 75 cc 9b cc 83
a + ◌̣ 2 61 cc a3
đ đ 1 c4 91

đ không phân rã được. Nó không phải "d + một dấu gì" trong Unicode — nó là một ký tự độc lập (U+0111 LATIN SMALL LETTER D WITH STROKE), giống như ø của tiếng Đan Mạch. Không có dấu nào để xoá.

Nên nó đi nguyên vẹn qua bước 2, rồi đến bước 3 — nơi chỉ giữ [a-z0-9] — và bị xoá thẳng:

Tiêu đề Không có bước 1 Có bước 1
Đường đến với Golang uong-en-voi-golang duong-den-voi-golang
đ Đ đơn giản on-gian d-d-don-gian
Xin chào Việt Nam xin-chao-viet-nam xin-chao-viet-nam

Chú ý dòng thứ ba: những tiêu đề không có đ vẫn ra đúng. Đó là điều làm lỗi này nguy hiểm — nó qua được mọi bài test viết bằng "Xin chào Việt Nam", và chỉ lộ ra khi có người viết bài về "Đường đến với Golang". Lúc đó URL đã phát hành, đã có người bookmark, và sửa slug nghĩa là làm hỏng link cũ.

slug_test.go§16.1 khoá hành vi này lại bằng đúng ba dòng ví dụ trên.

Vì sao Đ cũng thành d chứ không phải D? Vì bước 3 sẽ hạ về chữ thường ngay sau đó. Map thẳng sang d bỏ được một lần chuyển đổi và, quan trọng hơn, tránh được cái bẫy strings.ToLower với ký tự đặc biệt của một số ngôn ngữ khác.

6.2.2 Vì sao luôn thêm hậu tố, thay vì chỉ thêm khi trùng

Hai bài cùng tên "Học Go từ đầu" cho cùng một slug. Ba cách xử lý:

Cách Vấn đề
Trả lỗi "tiêu đề đã tồn tại" Không chấp nhận được. Hai người có quyền viết bài cùng tên
Thử slug sạch trước, trùng thì thêm hậu tố Cần bắt lỗi 23505 rồi chạy lại cả transaction — kèm cả lần ghi outbox. Vòng lặp thử lại trong đường ghi, để đổi lấy một URL đẹp hơn
Luôn thêm hậu tố 8 ký tự từ id ← chọn URL dài thêm 9 ký tự

Cách thứ ba thắng vì nó tất định: không nhánh rẽ, không thử lại, không cần biết trong database đang có gì. Slug được tính xong trước khi mở transaction, và INSERT không bao giờ thất bại vì lý do này.

Đây cũng là cách DEV.to làm — URL thật của họ có dạng .../my-post-title-4f2a. Khi thấy một sản phẩm lớn chấp nhận URL kém đẹp hơn, thường là vì họ đã trả giá cho cách còn lại.

8 ký tự hex = 32 bit. Để hai bài cùng tiêu đề trùng luôn cả hậu tố cần khoảng 77.000 bài cùng tên — và UNIQUE§3.1 vẫn đứng đó cho trường hợp đó.

6.3 ReadTime — ba quyết định nhỏ, ba lỗi tránh được

minutes := (words + wordsPerMinute - 1) / wordsPerMinute
if minutes < 1 { return 1 }
Quyết định Nếu làm ngược lại
Làm tròn lên, không làm tròn thường 201 từ hiện "1 phút đọc". Người đọc thấy sai và mất tin vào con số
Tối thiểu 1 phút Bài 10 từ hiện "0 phút đọc" — và CHECK (read_time >= 1) chặn INSERT, biến chuyện thẩm mỹ thành lỗi 500
Tính lúc ghi, không tính lúc đọc Trang chủ 20 bài phải đếm từ của 20 body — mỗi lần tải trang. Tính một lần, đọc nghìn lần

Và hai giới hạn cần biết trước:

strings.Fields đếm theo khoảng trắng. Đúng với tiếng Việt và tiếng Anh. Sai hoàn toàn với tiếng Trung/Nhật — chuỗi 中文没有空格分词 đếm ra 1 từ, nên một bài 5.000 chữ Hán hiện "1 phút đọc". Nếu nền tảng của bạn có nội dung CJK, cần đếm theo rune với hệ số riêng (~400 chữ/phút).

Body là Markdown. Khối code, URL, cú pháp bảng đều bị đếm như từ. Một bài viết kỹ thuật với 300 dòng code sẽ báo thời gian đọc gấp đôi thực tế. Cách sửa đúng là bỏ khối code trước khi đếm; tôi để nguyên ở bước này vì nó cần một trình phân tích Markdown, và đó là một phụ thuộc đáng suy nghĩ riêng chứ không nên nhét vào đây.

Cài thư viện:

go get golang.org/x/text

golang.org/x/text đã có trong go.sum như phụ thuộc gián tiếp của pgx. Lệnh trên chỉ chuyển nó thành phụ thuộc trực tiếp — điều nên làm khi code của bạn import trực tiếp, để go mod tidy không gỡ nó đi.


7. post/repository

Cùng khuôn với bước 4 §12: dịch giữa gen.Postdomain.Post, dịch lỗi Postgres thành lỗi nghiệp vụ.

internal/modules/post/repository/postgres.go:

package repository

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/chuongtd/community/internal/modules/post/domain"
	"github.com/chuongtd/community/internal/modules/post/repository/gen"
)

// PageSizeMax chặn client yêu cầu 10.000 bài một lượt.
const PageSizeMax = 50

type Postgres struct {
	pool *pgxpool.Pool
}

func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} }

// Insert ghi bài viết TRONG transaction do service mở.
func (r *Postgres) Insert(ctx context.Context, tx pgx.Tx, p domain.Post) (domain.Post, error) {
	row, err := gen.New(tx).CreatePost(ctx, gen.CreatePostParams{
		ID:       p.ID,
		AuthorID: p.AuthorID,
		Title:    p.Title,
		Slug:     p.Slug,
		Body:     p.Body,
		ReadTime: int32(p.ReadTime),
	})
	if err != nil {
		return domain.Post{}, fmt.Errorf("post: tạo bài viết: %w", err)
	}
	return toDomain(row), nil
}

// DeleteByAuthor xoá và trả về bài vừa xoá.
//
// Trả ErrPostNotFound cho CẢ HAI trường hợp "không tồn tại" và "không
// phải của bạn" — cố ý, xem §8.3.
func (r *Postgres) DeleteByAuthor(ctx context.Context, tx pgx.Tx, id, authorID uuid.UUID) (domain.Post, error) {
	row, err := gen.New(tx).DeletePostByAuthor(ctx, gen.DeletePostByAuthorParams{
		ID:       id,
		AuthorID: authorID,
	})
	return mapRow(row, err)
}

func (r *Postgres) FindByID(ctx context.Context, id uuid.UUID) (domain.Post, error) {
	row, err := gen.New(r.pool).GetPostByID(ctx, id)
	return mapRow(row, err)
}

func (r *Postgres) FindBySlug(ctx context.Context, slug string) (domain.Post, error) {
	row, err := gen.New(r.pool).GetPostBySlug(ctx, slug)
	return mapRow(row, err)
}

// Cursor là vị trí phân trang. Con trỏ nil nghĩa là trang đầu.
type Cursor struct {
	CreatedAt time.Time
	ID        uuid.UUID
}

// List trả về một trang bài viết, mới nhất trước.
//
// authorID = uuid.Nil ⇒ toàn hệ thống; ngược lại ⇒ của riêng tác giả đó.
func (r *Postgres) List(ctx context.Context, authorID uuid.UUID, after *Cursor, size int) ([]domain.Post, error) {
	if size <= 0 || size > PageSizeMax {
		size = PageSizeMax
	}
	q := gen.New(r.pool)
	limit := int32(size)

	var (
		rows []gen.Post
		err  error
	)
	switch {
	case authorID != uuid.Nil && after != nil:
		rows, err = q.ListPostsByAuthorAfter(ctx, gen.ListPostsByAuthorAfterParams{
			AuthorID: authorID, CreatedAt: after.CreatedAt, ID: after.ID, Limit: limit,
		})
	case authorID != uuid.Nil:
		rows, err = q.ListPostsByAuthorFirstPage(ctx, gen.ListPostsByAuthorFirstPageParams{
			AuthorID: authorID, Limit: limit,
		})
	case after != nil:
		rows, err = q.ListPostsAfter(ctx, gen.ListPostsAfterParams{
			CreatedAt: after.CreatedAt, ID: after.ID, Limit: limit,
		})
	default:
		rows, err = q.ListPostsFirstPage(ctx, limit)
	}
	if err != nil {
		return nil, fmt.Errorf("post: liệt kê bài viết: %w", err)
	}

	out := make([]domain.Post, len(rows))
	for i, row := range rows {
		out[i] = toDomain(row)
	}
	return out, nil
}

func (r *Postgres) CountByAuthor(ctx context.Context, authorID uuid.UUID) (int64, error) {
	n, err := gen.New(r.pool).CountPostsByAuthor(ctx, authorID)
	if err != nil {
		return 0, fmt.Errorf("post: đếm bài viết: %w", err)
	}
	return n, nil
}

func mapRow(row gen.Post, err error) (domain.Post, error) {
	switch {
	case errors.Is(err, pgx.ErrNoRows):
		return domain.Post{}, domain.ErrPostNotFound
	case err != nil:
		return domain.Post{}, fmt.Errorf("post: đọc bài viết: %w", err)
	}
	return toDomain(row), nil
}

func toDomain(p gen.Post) domain.Post {
	return domain.Post{
		ID:        p.ID,
		AuthorID:  p.AuthorID,
		Title:     p.Title,
		Slug:      p.Slug,
		Body:      p.Body,
		ReadTime:  int(p.ReadTime),
		CreatedAt: p.CreatedAt,
		UpdatedAt: p.UpdatedAt,
	}
}

switch bốn nhánh trông thô, và đó là chỗ nó thuộc về. Bốn tổ hợp tham số → bốn query có kế hoạch tối ưu riêng (§4). Sự thô này bị giới hạn trong đúng một hàm ở tầng repository; servicetransport phía trên chỉ thấy một hàm List gọn gàng. Đó là công việc của một tầng chuyển đổi: hấp thụ sự thô, không lan truyền nó.


8. post/service

8.1 service.go

// Package service chứa nghiệp vụ của module post.
// Không biết HTTP, không biết Kafka, không biết sqlc.
package service

import (
	"context"
	"log/slog"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"

	"github.com/chuongtd/community/internal/contracts"
	"github.com/chuongtd/community/internal/modules/post/domain"
	"github.com/chuongtd/community/internal/modules/post/repository"
)

type Transactor interface {
	Do(ctx context.Context, fn func(tx pgx.Tx) error) error
}

type Repository interface {
	Insert(ctx context.Context, tx pgx.Tx, p domain.Post) (domain.Post, error)
	DeleteByAuthor(ctx context.Context, tx pgx.Tx, id, authorID uuid.UUID) (domain.Post, error)
	FindByID(ctx context.Context, id uuid.UUID) (domain.Post, error)
	FindBySlug(ctx context.Context, slug string) (domain.Post, error)
	List(ctx context.Context, authorID uuid.UUID, after *repository.Cursor, size int) ([]domain.Post, error)
}

type EventWriter interface {
	Write(ctx context.Context, tx pgx.Tx, topic string, e contracts.Envelope) error
}

type Service struct {
	tx     Transactor
	repo   Repository
	events EventWriter
	log    *slog.Logger
	now    func() time.Time
}

func New(tx Transactor, repo Repository, events EventWriter, log *slog.Logger) *Service {
	return &Service{
		tx: tx, repo: repo, events: events, log: log,
		now: func() time.Time { return time.Now().UTC() },
	}
}

Bốn interface, cùng ba cái tên đã dùng ở identity/service. Đây không phải trùng lặp đáng gỡ. Nếu gom TransactorEventWriter vào một package chung, hai module lập tức có một phụ thuộc chung ở tầng nghiệp vụ, và ngày post cần thêm một phương thức vào EventWriter thì identity bị kéo theo. Ba dòng khai báo lặp lại là cái giá rất rẻ để hai module tiến hoá độc lập — đúng tinh thần Luật 1.

8.2 create.go

package service

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5"

	"github.com/chuongtd/community/internal/contracts"
	v1 "github.com/chuongtd/community/internal/contracts/v1"
	"github.com/chuongtd/community/internal/modules/post/domain"
)

func (s *Service) Create(ctx context.Context, d domain.Draft) (domain.Post, error) {
	// 1. Kiểm tra — không chạm DB.
	if err := d.Validate(); err != nil {
		return domain.Post{}, err
	}

	// 2. Slug và read_time tính NGOÀI transaction.
	//    Cùng lý lẽ với bcrypt ở bước 4 §3: đây là việc thuần CPU, không
	//    có lý do gì để nó giữ một kết nối DB. Rẻ hơn bcrypt rất nhiều,
	//    nhưng nguyên tắc thì không phụ thuộc vào con số.
	post := domain.NewPost(d)

	// 3. Ghi bài và ghi event trong CÙNG một transaction.
	var created domain.Post
	err := s.tx.Do(ctx, func(tx pgx.Tx) error {
		saved, err := s.repo.Insert(ctx, tx, post)
		if err != nil {
			return err
		}

		event, err := contracts.NewEnvelope(
			v1.TypePostCreated,
			saved.ID.String(), // AggregateID → partition key
			v1.PostCreated{
				PostID:   saved.ID.String(),
				AuthorID: saved.AuthorID.String(),
				Title:    saved.Title,
				Slug:     saved.Slug,
				ReadTime: saved.ReadTime,
				// Thời điểm LÚC TẠO, không phải lúc consumer đọc (§5.1).
				CreatedAt: saved.CreatedAt,
			},
		)
		if err != nil {
			return fmt.Errorf("post: dựng event: %w", err)
		}

		if err := s.events.Write(ctx, tx, contracts.TopicPost, event); err != nil {
			return fmt.Errorf("post: ghi outbox: %w", err)
		}

		created = saved
		return nil
	})
	if err != nil {
		return domain.Post{}, err
	}

	s.log.Info("tạo bài viết",
		"post_id", created.ID.String(),
		"author_id", created.AuthorID.String(),
		"slug", created.Slug)

	return created, nil
}

AggregateIDpost_id, không phải author_id. Quyết định này chọn cái gì được đảm bảo thứ tự: mọi event của cùng một bài viết vào cùng partition, nên "tạo rồi xoá" không bao giờ tới ngược. Nếu chọn author_id, bạn được thứ tự theo tác giả nhưng mất thứ tự theo bài viết — và bài viết là thực thể mà event này nói về. Quy tắc: AggregateID là ID của thực thể mà event mô tả, không phải thực thể liên quan.

8.3 delete.go và vì sao xoá bài của người khác cũng trả 404

package service

import (
	"context"
	"fmt"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"

	"github.com/chuongtd/community/internal/contracts"
	v1 "github.com/chuongtd/community/internal/contracts/v1"
	"github.com/chuongtd/community/internal/modules/post/domain"
)

// Delete xoá bài viết. Chỉ tác giả xoá được bài của mình.
func (s *Service) Delete(ctx context.Context, id, authorID uuid.UUID) error {
	return s.tx.Do(ctx, func(tx pgx.Tx) error {
		// Quyền được kiểm TRONG câu DELETE (WHERE author_id = $2), không
		// phải bằng một lần SELECT trước đó. Xem ghi chú dưới.
		deleted, err := s.repo.DeleteByAuthor(ctx, tx, id, authorID)
		if err != nil {
			return err // ErrPostNotFound đi thẳng ra ngoài
		}

		event, err := contracts.NewEnvelope(
			v1.TypePostDeleted,
			deleted.ID.String(),
			v1.PostDeleted{
				PostID: deleted.ID.String(),
				// ★ BẮT BUỘC. Tới lúc consumer chạy, dòng posts đã biến
				//   mất và không ai tra được author_id nữa (§5.1).
				AuthorID: deleted.AuthorID.String(),
			},
		)
		if err != nil {
			return fmt.Errorf("post: dựng event: %w", err)
		}

		return s.events.Write(ctx, tx, contracts.TopicPost, event)
	})
}

Vì sao kiểm quyền trong câu DELETE. Cách viết tự nhiên là đọc trước, so chủ sở hữu, rồi xoá:

// ❌ SAI — có khoảng trống giữa hai lệnh
p, err := repo.FindByID(ctx, id)
if p.AuthorID != authorID { return ErrForbidden }
repo.Delete(ctx, tx, id)

Ngoài chuyện thêm một lượt đi về DB, nó còn có khoảng trống cho tranh chấp đồng thời — đúng lỗi đã gặp hai lần trong tài liệu này: MarkConsumedInsert user. WHERE id = $1 AND author_id = $2 để database làm cả hai việc trong một thao tác nguyên tử.

Vì sao trả 404 chứ không phải 403. Nếu trả 403 Forbidden khi bài tồn tại nhưng không phải của bạn, endpoint này trở thành công cụ dò:

DELETE /posts/<id-đoán>  → 404  ⇒ id này không tồn tại
DELETE /posts/<id-đoán>  → 403  ⇒ id này TỒN TẠI, của người khác

Kẻ tấn công lập được danh sách id hợp lệ mà không xoá được gì. Đây đúng là kênh rò rỉ đã đóng ở bước 4 §7.2 cho việc dò email, xuất hiện lần thứ hai ở một chỗ khác hoàn toàn.

Trả 404 cho cả hai nghĩa là client không phân biệt được, và đó chính là mục đích. Đổi lại, thông báo lỗi kém hữu ích hơn một chút cho người dùng thật — nhưng người dùng thật không xoá bài của người khác.

8.4 read.go

package service

import (
	"context"

	"github.com/google/uuid"

	"github.com/chuongtd/community/internal/contracts"
	v1 "github.com/chuongtd/community/internal/contracts/v1"
	"github.com/chuongtd/community/internal/modules/post/domain"
	"github.com/chuongtd/community/internal/modules/post/repository"
	"github.com/jackc/pgx/v5"
)

func (s *Service) GetBySlug(ctx context.Context, slug string) (domain.Post, error) {
	return s.repo.FindBySlug(ctx, slug)
}

func (s *Service) List(ctx context.Context, authorID uuid.UUID, after *repository.Cursor, size int) ([]domain.Post, error) {
	return s.repo.List(ctx, authorID, after, size)
}

// RecordView phát event lượt xem.
//
// ⚠️ Thao tác này KHÔNG cập nhật gì trong module post — nó chỉ phát event.
// Việc đếm thuộc về module stats. Và nó là ngoại lệ duy nhất của tài liệu
// này về mặt idempotency: xem §13 trước khi sửa nó.
func (s *Service) RecordView(ctx context.Context, postID, viewerID uuid.UUID) error {
	// Cần author_id để consumer không phải tra ngược (§5.1). Bài viết vẫn
	// còn tồn tại lúc này, nên đọc được — khác với trường hợp Delete.
	post, err := s.repo.FindByID(ctx, postID)
	if err != nil {
		return err
	}

	return s.tx.Do(ctx, func(tx pgx.Tx) error {
		event, err := contracts.NewEnvelope(
			v1.TypePostViewed,
			post.ID.String(),
			v1.PostViewed{
				PostID:   post.ID.String(),
				AuthorID: post.AuthorID.String(),
				ViewerID: viewerID.String(),
			},
		)
		if err != nil {
			return err
		}
		return s.events.Write(ctx, tx, contracts.TopicPost, event)
	})
}

9. post/transport/http

9.1 dto.go

package http

import "time"

type CreateRequest struct {
	Title string `json:"title"`
	Body  string `json:"body"`
}

// PostResponse liệt kê TƯỜNG MINH từng trường.
// Không trả domain.Post hay gen.Post — lý lẽ ở bước 4 §14.2.
type PostResponse struct {
	ID        string    `json:"id"`
	AuthorID  string    `json:"author_id"`
	Title     string    `json:"title"`
	Slug      string    `json:"slug"`
	Body      string    `json:"body"`
	ReadTime  int       `json:"read_time"`
	CreatedAt time.Time `json:"created_at"`
}

// PostSummary là bản rút gọn cho danh sách — KHÔNG có Body.
//
// Trang chủ 20 bài × 50 KB body = 1 MB cho một danh sách mà giao diện
// chỉ hiện tiêu đề. Đây là lý do danh sách và chi tiết là hai kiểu khác
// nhau, không phải cùng một kiểu dùng hai chỗ.
type PostSummary struct {
	ID        string    `json:"id"`
	AuthorID  string    `json:"author_id"`
	Title     string    `json:"title"`
	Slug      string    `json:"slug"`
	ReadTime  int       `json:"read_time"`
	CreatedAt time.Time `json:"created_at"`
}

type ListResponse struct {
	Items []PostSummary `json:"items"`

	// NextCursor rỗng nghĩa là hết. Client chỉ cần chuyển tiếp chuỗi này
	// vào tham số ?after= — nó KHÔNG cần hiểu bên trong có gì (Phụ lục A).
	NextCursor string `json:"next_cursor,omitempty"`
}

9.2 handler.go

package http

import (
	"encoding/base64"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"

	"github.com/chuongtd/community/internal/modules/post/domain"
	"github.com/chuongtd/community/internal/modules/post/repository"
	"github.com/chuongtd/community/internal/modules/post/service"
	"github.com/chuongtd/community/internal/platform/httpx"
	"github.com/chuongtd/community/internal/platform/httpx/middleware"
)

type Handler struct {
	svc *service.Service
	log *slog.Logger
}

func NewHandler(svc *service.Service, log *slog.Logger) *Handler {
	return &Handler{svc: svc, log: log}
}

func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
	authorID, ok := subject(r)
	if !ok {
		httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "token không hợp lệ")
		return
	}

	var req CreateRequest
	if err := httpx.DecodeJSON(w, r, &req); err != nil {
		httpx.Fail(w, r, http.StatusBadRequest, "bad_request", err.Error())
		return
	}

	post, err := h.svc.Create(r.Context(), domain.Draft{
		AuthorID: authorID,
		Title:    req.Title,
		Body:     req.Body,
	})
	if err != nil {
		h.fail(w, r, err)
		return
	}

	// 201 kèm Location — chuẩn REST cho việc tạo tài nguyên, và cũng là
	// cách client biết slug đã được sinh ra thế nào.
	w.Header().Set("Location", "/posts/"+post.Slug)
	httpx.JSON(w, http.StatusCreated, toResponse(post))
}

func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
	authorID, ok := subject(r)
	if !ok {
		httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "token không hợp lệ")
		return
	}
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		httpx.Fail(w, r, http.StatusBadRequest, "invalid_id", "id không phải UUID")
		return
	}

	if err := h.svc.Delete(r.Context(), id, authorID); err != nil {
		h.fail(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) handleGetBySlug(w http.ResponseWriter, r *http.Request) {
	post, err := h.svc.GetBySlug(r.Context(), chi.URLParam(r, "slug"))
	if err != nil {
		h.fail(w, r, err)
		return
	}
	httpx.JSON(w, http.StatusOK, toResponse(post))
}

func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	var authorID uuid.UUID
	if raw := q.Get("author_id"); raw != "" {
		id, err := uuid.Parse(raw)
		if err != nil {
			httpx.Fail(w, r, http.StatusBadRequest, "invalid_author_id", "author_id không phải UUID")
			return
		}
		authorID = id
	}

	size := 20
	if raw := q.Get("size"); raw != "" {
		n, err := strconv.Atoi(raw)
		if err != nil || n <= 0 {
			httpx.Fail(w, r, http.StatusBadRequest, "invalid_size", "size phải là số nguyên dương")
			return
		}
		size = n // repository tự chặn trần PageSizeMax
	}

	var after *repository.Cursor
	if raw := q.Get("after"); raw != "" {
		c, err := decodeCursor(raw)
		if err != nil {
			httpx.Fail(w, r, http.StatusBadRequest, "invalid_cursor", "con trỏ phân trang không hợp lệ")
			return
		}
		after = c
	}

	posts, err := h.svc.List(r.Context(), authorID, after, size)
	if err != nil {
		h.fail(w, r, err)
		return
	}

	resp := ListResponse{Items: make([]PostSummary, len(posts))}
	for i, p := range posts {
		resp.Items[i] = toSummary(p)
	}
	// Chỉ có con trỏ tiếp khi trang này ĐẦY. Trang chưa đầy ⇒ đã hết dữ liệu.
	if len(posts) == size {
		last := posts[len(posts)-1]
		resp.NextCursor = encodeCursor(repository.Cursor{CreatedAt: last.CreatedAt, ID: last.ID})
	}
	httpx.JSON(w, http.StatusOK, resp)
}

func (h *Handler) handleView(w http.ResponseWriter, r *http.Request) {
	viewerID, ok := subject(r)
	if !ok {
		httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "token không hợp lệ")
		return
	}
	id, err := uuid.Parse(chi.URLParam(r, "id"))
	if err != nil {
		httpx.Fail(w, r, http.StatusBadRequest, "invalid_id", "id không phải UUID")
		return
	}

	if err := h.svc.RecordView(r.Context(), id, viewerID); err != nil {
		h.fail(w, r, err)
		return
	}
	// 202 Accepted, không phải 200: việc đếm CHƯA xảy ra lúc trả lời.
	// Mã trạng thái nói đúng sự thật về nhất quán cuối cùng.
	w.WriteHeader(http.StatusAccepted)
}

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí