0

#5 Bước 4 — Dựng module `identity` (Quản lý User & Auth) Phần 1

Tài liệu này nối tiếp Bước 3 — Migrations & Code Gen. Kiến trúc tổng thể: ARCHITECTURE.md.

Đây là bước đầu tiên có nghiệp vụ thật. Ba bước trước dựng hạ tầng; bước này dùng hạ tầng đó để làm một việc cụ thể: một người lạ gửi request đăng ký, hệ thống tạo tài khoản, cấp token, và ghi một event vào bảng outbox trong cùng một transaction.


Mục tiêu

Kết thúc bước này bạn có:

  • POST /auth/register — đăng ký, trả về người dùng + JWT
  • POST /auth/login — đăng nhập, trả về JWT
  • GET /me — endpoint được bảo vệ bằng bearer token
  • Mật khẩu được băm bằng bcrypt, không bao giờ rời khỏi server dưới dạng gốc
  • Mỗi lần đăng ký thành công sinh đúng một dòng identity.user.registered.v1 trong bảng outbox
  • Nếu bất kỳ bước nào lỗi: không có user, và cũng không có event — Luật 3 chạy thật, không còn là lý thuyết

Và quan trọng không kém — bạn thấy rõ ranh giới module: identity tạo user nhưng không đụng vào bảng user_stats, dù bảng đó cần một dòng mới. Vì sao lại thế là nội dung §18.


Mục lục

  1. Phạm vi bước này
  2. Cài thư viện
  3. Nhìn từ trên xuống
  4. platform/config
  5. platform/logger
  6. platform/requestidplatform/httpx
  7. platform/password
  8. platform/token — JWT
  9. platform/outbox/writer.go
  10. contracts/v1/user_events.go
  11. identity/domain
  12. identity/repository
  13. identity/service
  14. identity/transport/http
  15. Lắp ráp: module.gocmd/api
  16. Chạy thử đầu-cuối
  17. Kiểm chứng bằng test
  18. Ai tạo dòng user_stats?
  19. Những thứ cố ý chưa có
  20. Checklist hoàn thành

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

1.1 Cái đã có sẵn

Thứ Xong ở Ghi chú
Bảng users (000001) Bước 3 Đủ mọi cột module này cần
Bảng outbox (000003) Bước 3 Chỗ event đi vào
Query CreateUser, GetUserByID, GetUserByEmail Bước 3 Đã sqlc generate
contracts.Envelope, TopicIdentity Bước 2
postgres.TxManager Bước 2 Trái tim của Luật 3

1.2 ⚠️ Bước này không thêm migration nào

Đề bài của bước ghi "Tạo DB Schema & Migration: xây dựng bảng users và các migration liên quan". Bảng users đã được tạo ở bước 3, và khi rà lại từng thứ module này cần, không có cột nào thiếu:

Nhu cầu của module Cột đã có
Đăng nhập bằng email email + CONSTRAINT users_email_key UNIQUE
Kiểm tra mật khẩu password_hash VARCHAR(255)
Định danh công khai username + CONSTRAINT users_username_key UNIQUE
Hiển thị profile name, avatar_url, reputation
aggregate_id cho event id UUID PRIMARY KEY

Nên tôi không tạo migration mới. Thêm cột chỉ vì đề bài có mục "Migration" là cách nhanh nhất để có schema đầy cột không ai đọc, và mỗi cột thừa là một thứ phải giữ tương thích mãi mãi.

Nếu sau này bạn thêm refresh token hay xác thực email, lúc đó mới cần migration — và Phụ lục có sẵn mẫu.

Điều cần xác nhận trước khi viết code là schema đang chạy khớp với schema bạn tưởng:

docker compose -f deployments/docker-compose.yml exec -T postgres `
  psql -U app -d community -c "\d users"

Phải thấy đủ hai ràng buộc users_email_keyusers_username_key. Đây không phải thủ tục cho có: §12 phân biệt "trùng email" với "trùng username" bằng chính tên hai ràng buộc này. Tên sai thì code vẫn biên dịch được, chỉ là người dùng nhận sai thông báo lỗi.

1.3 Cái sẽ viết

internal/
├── contracts/v1/user_events.go          ← MỚI
├── platform/
│   ├── config/config.go                 ← MỚI
│   ├── logger/logger.go                 ← MỚI
│   ├── requestid/requestid.go           ← MỚI
│   ├── httpx/{response.go, errors.go}   ← MỚI
│   ├── httpx/middleware/{request_id.go, recover.go, auth.go}  ← MỚI
│   ├── password/password.go             ← MỚI
│   ├── token/token.go                   ← MỚI
│   ├── postgres/errors.go               ← thêm 1 hàm
│   └── outbox/writer.go                 ← MỚI
├── modules/identity/
│   ├── domain/{user.go, errors.go}      ← MỚI
│   ├── repository/postgres.go           ← MỚI
│   ├── service/{service.go, register.go, login.go}  ← MỚI
│   ├── transport/http/{handler.go, router.go, dto.go}  ← MỚI
│   └── module.go                        ← MỚI
└── cmd/api/main.go                      ← MỚI

Nhiều file, nhưng phần lớn là platform/ — viết một lần, mọi module sau dùng lại. Module post ở bước sau sẽ chỉ còn bốn thư mục nghiệp vụ.


2. Cài thư viện

go get github.com/go-chi/chi/v5
go get github.com/golang-jwt/jwt/v5
go get github.com/caarlos0/env/v11
go get golang.org/x/crypto/bcrypt
Thư viện Dùng để Vì sao chọn nó
go-chi/chi/v5 HTTP router Dùng thẳng http.Handler chuẩn, không có kiểu context riêng. Middleware viết cho chi chạy được ở mọi nơi và ngược lại
golang-jwt/jwt/v5 Ký / kiểm JWT v5 bắt buộc khai báo thuật toán hợp lệ — chặn sẵn lỗ hổng alg§8.3
caarlos0/env/v11 Đọc biến môi trường Khai báo bằng tag, tự ép kiểu, tự báo thiếu biến bắt buộc
golang.org/x/crypto/bcrypt Băm mật khẩu Chậm có chủ đích, tự sinh salt, tự lưu cost trong chuỗi hash

Vì sao bcrypt mà không phải SHA-256?

Vì SHA-256 được thiết kế để nhanh — GPU hiện nay thử được hàng tỷ chuỗi mỗi giây. Với bcrypt cost 12, mỗi lần thử tốn khoảng 250 mili giây, tức chậm hơn chừng mười triệu lần. Cùng một danh sách mật khẩu bị lộ: SHA-256 vỡ trong vài giờ, bcrypt cần nhiều năm.

Argon2id hiện đại hơn và cũng là lựa chọn đúng. Tôi chọn bcrypt vì API chỉ có hai hàm, không có tham số nào để cấu hình sai — mà cấu hình sai Argon2 (đặt bộ nhớ quá thấp) thì trông vẫn "an toàn" và không có gì báo cho bạn biết.


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

Trước khi đi vào từng file, đây là toàn bộ hành trình của một request đăng ký:

POST /auth/register
  │
  ├─ middleware.RequestID   → sinh/nhận X-Request-ID, gắn vào ctx
  ├─ middleware.Recover     → panic không làm sập server
  │
  ├─ transport/http         → giải mã JSON, đổi DTO thành domain.Registration
  │
  └─ service.Register
       ├─ 1. domain.Registration.Validate()      (không chạm DB)
       ├─ 2. password.Hash()                     (~250ms CPU — NGOÀI transaction)
       │
       └─ 3. txm.Do  ─────────────── BEGIN ──────────────────────┐
              ├─ repo.Insert(tx, user)      → INSERT INTO users  │
              ├─ contracts.NewEnvelope(...) → dựng event         │
              └─ outbox.Write(tx, ...)      → INSERT INTO outbox │
                                    ────── COMMIT ───────────────┘
       │
       ├─ 4. tokens.Issue()                      (sau khi commit)
       └─ trả 201 + user + JWT

Ba chi tiết trong sơ đồ này quyết định chất lượng của cả module:

Băm mật khẩu nằm ngoài transaction. bcrypt cost 12 tốn khoảng 250ms CPU. Nếu đặt nó bên trong txm.Do, mỗi request đăng ký sẽ giữ một kết nối DB và một transaction mở suốt 250ms mà không làm gì với database cả. Với MaxConns = 10 (bước 2 §3.2), chỉ cần 40 lượt đăng ký mỗi giây là pool cạn sạch — và triệu chứng lại trông y hệt "database quá tải", dù database đang rảnh rỗi. Đây là loại lỗi rất khó tìm vì đoạn code trông hoàn toàn hợp lý.

Cấp token nằm sau COMMIT. Token là thứ ta trả cho client, không phải dữ liệu cần bảo toàn. Đưa nó vào transaction chỉ kéo dài thời gian giữ khoá.

INSERT usersINSERT outbox nằm chung một transaction. Đây là Luật 3, và là toàn bộ lý do bảng outbox tồn tại.


4. platform/config

Tạo internal/platform/config/config.go:

// Package config đọc cấu hình từ biến môi trường.
//
// Nguyên tắc: đọc MỘT LẦN lúc khởi động, sai thì dừng ngay.
// Không có hàm nào đọc os.Getenv rải rác trong code nghiệp vụ.
package config

import (
	"fmt"
	"time"

	"github.com/caarlos0/env/v11"
)

type Config struct {
	DatabaseURL  string        `env:"DATABASE_URL,required"`
	KafkaBrokers []string      `env:"KAFKA_BROKERS" envSeparator:","`
	HTTPPort     int           `env:"HTTP_PORT" envDefault:"8000"`
	LogLevel     string        `env:"LOG_LEVEL" envDefault:"info"`

	// JWTSecret ký token HS256. KHÔNG có giá trị mặc định — xem ghi chú dưới.
	JWTSecret string `env:"JWT_SECRET,required"`

	// JWTTTL là thời gian sống của access token.
	JWTTTL time.Duration `env:"JWT_TTL" envDefault:"24h"`
}

// MinJWTSecretLen là độ dài tối thiểu cho khoá HS256.
// HMAC-SHA256 có khối 256 bit; khoá ngắn hơn làm giảm sức mạnh thực tế
// mà không có gì cảnh báo bạn.
const MinJWTSecretLen = 32

func Load() (Config, error) {
	var cfg Config
	if err := env.Parse(&cfg); err != nil {
		return Config{}, fmt.Errorf("config: %w", err)
	}
	if len(cfg.JWTSecret) < MinJWTSecretLen {
		return Config{}, fmt.Errorf(
			"config: JWT_SECRET phải dài ít nhất %d ký tự (đang có %d)",
			MinJWTSecretLen, len(cfg.JWTSecret))
	}
	if cfg.JWTTTL <= 0 {
		return Config{}, fmt.Errorf("config: JWT_TTL phải lớn hơn 0")
	}
	return cfg, nil
}

4.1 Vì sao JWT_SECRET không có giá trị mặc định

Đây là quyết định có chủ đích, dù nó làm bạn khó chịu ở lần chạy đầu tiên.

Một giá trị mặc định kiểu envDefault:"dev-secret" sẽ khiến ứng dụng chạy được ngay — và cũng chạy được y hệt trên production, nơi không ai nhớ đặt biến đó. Lúc ấy bất kỳ ai đọc mã nguồn đều tự ký được token cho bất kỳ tài khoản nào.

Bí mật thì phải thiếu là dừng. Cấu hình tiện lợi thì mới có mặc định.

4.2 Bổ sung .env

Thêm vào .env.example (file được commit) và .env (file không commit):

# Auth — sinh giá trị mới cho mỗi môi trường, KHÔNG dùng lại giữa dev và prod
JWT_SECRET=doi-chuoi-nay-thanh-gia-tri-ngau-nhien-32-ky-tu-tro-len
JWT_TTL=24h

Sinh một secret thật:

$bytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes)

4.3 Nạp .env khi chạy go run

Go không tự đọc file .env — chỉ docker compose làm việc đó. Chạy go run ./cmd/api mà không nạp gì, bạn sẽ nhận đúng lỗi DATABASE_URL is required.

Thêm vào dev.ps1 (đã tạo ở bước 1 §8):

function Import-DotEnv {
    param([string]$Path = ".env")
    if (-not (Test-Path $Path)) { throw "Không thấy $Path" }
    Get-Content $Path | ForEach-Object {
        if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') {
            [Environment]::SetEnvironmentVariable($Matches[1].Trim(), $Matches[2].Trim(), 'Process')
        }
    }
}

Dùng:

. .\dev.ps1
Import-DotEnv
go run ./cmd/api

Có thư viện github.com/joho/godotenv làm việc này ngay trong Go. Tôi không dùng vì nó tạo ra một khác biệt giữa dev và production: trên production không có file .env, cấu hình đến từ orchestrator. Giữ ứng dụng chỉ biết đọc biến môi trường thì hai môi trường chạy cùng một đường đi.


5. platform/logger

Tạo internal/platform/logger/logger.go:

// Package logger cấu hình log/slog cho toàn hệ thống.
package logger

import (
	"log/slog"
	"os"
	"strings"
)

// New trả về logger ghi JSON ra stdout.
//
// JSON chứ không phải text, vì log của hệ thống event-driven chỉ có ích khi
// bạn lọc được: "cho tôi mọi dòng có event_id = X". Với log dạng câu chữ,
// việc đó là grep và cầu may.
func New(level string) *slog.Logger {
	var lv slog.Level
	switch strings.ToLower(strings.TrimSpace(level)) {
	case "debug":
		lv = slog.LevelDebug
	case "warn", "warning":
		lv = slog.LevelWarn
	case "error":
		lv = slog.LevelError
	default:
		lv = slog.LevelInfo
	}

	return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: lv,
	}))
}

Ba quy ước dùng log trong dự án này, đáng thống nhất ngay từ bây giờ:

Quy ước Vì sao
Luôn kèm request_id hoặc event_id Một request đi qua HTTP → outbox → Kafka → consumer. Không có ID xuyên suốt thì không nối lại được
Không bao giờ log mật khẩu, password_hash, hay token Log thường được gửi sang hệ thống khác và giữ rất lâu
Lỗi 500 log đầy đủ, trả về client chung chung Chi tiết lỗi là thông tin cho kẻ tấn công, không phải cho người dùng

6. platform/requestidplatform/httpx

6.1 requestid — sợi chỉ xuyên suốt

Tạo internal/platform/requestid/requestid.go:

// Package requestid mang định danh của một request qua context.
//
// Package riêng (thay vì nằm trong httpx) vì cả tầng HTTP lẫn outbox writer
// đều cần tới nó, mà outbox không nên phải import cả gói HTTP để lấy một chuỗi.
package requestid

import (
	"context"

	"github.com/google/uuid"
)

// Header là tên header dùng để nhận và trả lại định danh.
const Header = "X-Request-ID"

// ctxKey là kiểu riêng tư — bảo đảm không package nào khác ghi đè
// giá trị này dù có dùng trùng tên khoá.
type ctxKey struct{}

func New() string { return uuid.NewString() }

func NewContext(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, ctxKey{}, id)
}

// From trả về "" nếu context không mang định danh nào.
func From(ctx context.Context) string {
	id, _ := ctx.Value(ctxKey{}).(string)
	return id
}

Vì sao type ctxKey struct{} mà không phải const key = "request_id"?

context.WithValue so khớp khoá bằng cả kiểu lẫn giá trị. Với khoá kiểu string, một thư viện bên thứ ba dùng đúng chuỗi "request_id" sẽ ghi đè giá trị của bạn — âm thầm. Với một kiểu không xuất khẩu, chuyện đó là bất khả thi: không ai bên ngoài package tạo được giá trị ctxKey{}.

Đây cũng là lý do go vet cảnh báo khi bạn dùng kiểu cơ bản làm khoá context.

6.2 httpx — trả lời có khuôn

Tạo internal/platform/httpx/response.go:

// Package httpx chứa tiện ích HTTP dùng chung. Không có nghiệp vụ.
package httpx

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
)

// MaxBodyBytes giới hạn kích thước body nhận vào.
const MaxBodyBytes = 1 << 20 // 1 MB

// JSON ghi body dạng JSON kèm status code.
func JSON(w http.ResponseWriter, status int, body any) {
	if body == nil {
		w.WriteHeader(status)
		return
	}

	// Mã hoá vào buffer TRƯỚC khi ghi header. Nếu mã hoá thẳng ra
	// ResponseWriter và nửa chừng gặp lỗi, header 200 đã gửi đi rồi —
	// client nhận một JSON cụt kèm mã thành công.
	var buf bytes.Buffer
	if err := json.NewEncoder(&buf).Encode(body); err != nil {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = w.Write([]byte(`{"error":{"code":"internal","message":"lỗi hệ thống"}}`))
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_, _ = w.Write(buf.Bytes())
}

// DecodeJSON đọc body vào dst, có chặn kích thước.
func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
		return fmt.Errorf("body không phải JSON hợp lệ: %w", err)
	}
	return nil
}

http.MaxBytesReader không phải chi tiết thừa. Không có nó, json.Decoder sẽ ngoan ngoãn đọc hết một body 5 GB vào RAM. Một request là đủ để giết tiến trình, và không cần kỹ năng gì để gửi.

Tạo internal/platform/httpx/errors.go:

package httpx

import (
	"net/http"

	"github.com/yourname/community/internal/platform/requestid"
)

type ErrorBody struct {
	// Code là mã máy đọc được: "email_taken", "invalid_credentials"...
	// Client dựa vào nó để xử lý, KHÔNG dựa vào Message.
	Code string `json:"code"`

	// Message dành cho con người đọc.
	Message string `json:"message"`
}

type ErrorResponse struct {
	Error ErrorBody `json:"error"`

	// RequestID để người dùng gửi kèm khi báo lỗi — bạn tra log ra ngay.
	RequestID string `json:"request_id,omitempty"`
}

func Fail(w http.ResponseWriter, r *http.Request, status int, code, message string) {
	JSON(w, status, ErrorResponse{
		Error:     ErrorBody{Code: code, Message: message},
		RequestID: requestid.From(r.Context()),
	})
}

httpx không được biết lỗi nghiệp vụ

Bạn sẽ rất muốn viết một hàm ngay tại đây:

// ❌ KHÔNG viết hàm này trong platform
func StatusFor(err error) int {
    if errors.Is(err, domain.ErrEmailTaken) { return http.StatusConflict }
    ...
}

Nó buộc platform/httpx phải import modules/identity/domain — vi phạm thẳng ARCHITECTURE.md §4.5: platform không được import modules. Và ngày thêm module thứ tư, cái switch đó phải sửa lại lần nữa.

Việc ánh xạ lỗi → HTTP status thuộc về tầng transport của từng module. Bạn sẽ thấy nó ở §14.3.

6.3 Middleware

Tạo internal/platform/httpx/middleware/request_id.go:

package middleware

import (
	"net/http"

	"github.com/yourname/community/internal/platform/requestid"
)

// RequestID nhận X-Request-ID từ client hoặc sinh mới, rồi gắn vào context
// và trả lại trong response header.
func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get(requestid.Header)

		// Nhận ID từ client cho phép nối vết qua nhiều service, nhưng đây là
		// dữ liệu người ngoài gửi vào: chặn độ dài để nó không thành một
		// chuỗi 10 MB nằm trong mọi dòng log.
		if id == "" || len(id) > 128 {
			id = requestid.New()
		}

		w.Header().Set(requestid.Header, id)
		next.ServeHTTP(w, r.WithContext(requestid.NewContext(r.Context(), id)))
	})
}

Tạo internal/platform/httpx/middleware/recover.go:

package middleware

import (
	"log/slog"
	"net/http"
	"runtime/debug"

	"github.com/yourname/community/internal/platform/httpx"
	"github.com/yourname/community/internal/platform/requestid"
)

// Recover biến panic trong handler thành 500 thay vì làm sập cả tiến trình.
func Recover(log *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				p := recover()
				if p == nil {
					return
				}
				// http.ErrAbortHandler là panic CÓ CHỦ ĐÍCH của net/http
				// để bỏ dở một response. Nuốt nó là làm hỏng cơ chế đó.
				if p == http.ErrAbortHandler {
					panic(p)
				}

				log.Error("panic trong handler",
					"panic", p,
					"method", r.Method,
					"path", r.URL.Path,
					"request_id", requestid.From(r.Context()),
					"stack", string(debug.Stack()))

				httpx.Fail(w, r, http.StatusInternalServerError, "internal", "lỗi hệ thống")
			}()

			next.ServeHTTP(w, r)
		})
	}
}

Tạo internal/platform/httpx/middleware/auth.go:

package middleware

import (
	"context"
	"net/http"
	"strings"

	"github.com/yourname/community/internal/platform/httpx"
)

// Verifier do bên gọi cung cấp. Nhờ interface này, middleware không biết
// gì về JWT — đổi sang phiên (session) hay khoá API chỉ cần một implement khác.
type Verifier interface {
	// Verify trả về subject (ở đây là user ID) nếu token hợp lệ.
	Verify(rawToken string) (subject string, err error)
}

type subjectKey struct{}

// Auth chặn request không có bearer token hợp lệ.
func Auth(v Verifier) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			raw, ok := bearer(r)
			if !ok {
				httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "thiếu bearer token")
				return
			}

			sub, err := v.Verify(raw)
			if err != nil {
				// KHÔNG trả err ra ngoài: thông báo "token hết hạn" và
				// "chữ ký sai" là hai mẩu thông tin khác nhau cho kẻ tấn công.
				httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "token không hợp lệ")
				return
			}

			ctx := context.WithValue(r.Context(), subjectKey{}, sub)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// Subject lấy user ID đã xác thực khỏi context.
func Subject(ctx context.Context) (string, bool) {
	s, ok := ctx.Value(subjectKey{}).(string)
	return s, ok && s != ""
}

func bearer(r *http.Request) (string, bool) {
	const prefix = "Bearer "
	h := r.Header.Get("Authorization")
	if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
		return "", false
	}
	token := strings.TrimSpace(h[len(prefix):])
	return token, token != ""
}

7. platform/password

Tạo internal/platform/password/password.go:

// Package password băm và kiểm tra mật khẩu.
//
// Không có nghiệp vụ nào ở đây — chép package này sang dự án Go khác
// là chạy được ngay, nên nó thuộc về platform (ARCHITECTURE.md §4.5).
package password

import (
	"errors"
	"fmt"
	"sync"

	"golang.org/x/crypto/bcrypt"
)

const (
	// Cost 12 ≈ 250ms trên CPU máy bàn năm 2024.
	// Thấp hơn thì dễ bị dò; cao hơn thì chính bạn tự tạo điểm nghẽn.
	Cost = 12

	// MaxLen là giới hạn CỨNG của thuật toán bcrypt, không phải lựa chọn
	// của chúng ta. Xem ghi chú bên dưới.
	MaxLen = 72
)

var (
	ErrTooLong  = errors.New("password: dài quá 72 byte")
	ErrMismatch = errors.New("password: không khớp")
)

func Hash(plain string) (string, error) {
	if len(plain) > MaxLen {
		return "", ErrTooLong
	}
	h, err := bcrypt.GenerateFromPassword([]byte(plain), Cost)
	if err != nil {
		return "", fmt.Errorf("password: băm thất bại: %w", err)
	}
	return string(h), nil
}

// Verify trả nil nếu đúng, ErrMismatch nếu sai.
// Cost được đọc từ chính chuỗi hash, nên đổi Cost không làm hỏng hash cũ.
func Verify(hash, plain string) error {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
	switch {
	case err == nil:
		return nil
	case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
		return ErrMismatch
	default:
		return fmt.Errorf("password: kiểm tra thất bại: %w", err)
	}
}

// dummyHash được tạo lười, chỉ khi lần đầu có ai đăng nhập bằng email không
// tồn tại. Tính sẵn lúc khởi động sẽ làm mọi lần chạy test chậm thêm 250ms.
var dummyHash = sync.OnceValue(func() []byte {
	h, err := bcrypt.GenerateFromPassword([]byte("chuoi-nay-khong-bao-gio-trung-khop"), Cost)
	if err != nil {
		panic(err) // chỉ xảy ra nếu Cost nằm ngoài khoảng hợp lệ — lỗi lập trình
	}
	return h
})

// Burn tiêu tốn đúng lượng thời gian như một lần Verify thật.
//
// Gọi nó khi KHÔNG tìm thấy người dùng, để thời gian phản hồi của
// "email không tồn tại" và "mật khẩu sai" giống nhau. Lý do đầy đủ ở §7.2.
func Burn(plain string) {
	_ = bcrypt.CompareHashAndPassword(dummyHash(), []byte(plain))
}

7.1 ⚠️ Giới hạn 72 byte của bcrypt

bcrypt chỉ nhìn 72 byte đầu tiên của mật khẩu. Đây là đặc điểm của thuật toán, không phải của thư viện.

Hệ quả nếu không xử lý: hai mật khẩu dài giống nhau ở 72 byte đầu nhưng khác nhau ở đuôi sẽ được coi là một. Người dùng cẩn thận nhất — người đặt passphrase 100 ký tự — lại là người bị ảnh hưởng.

Lưu ý thêm: 72 byte, không phải 72 ký tự. Tiếng Việt có dấu chiếm 2–3 byte mỗi ký tự trong UTF-8, nên "mật khẩu rất dài của tôi..." chạm giới hạn sớm hơn nhiều so với cảm giác. Đó là lý do §11 kiểm tra bằng len(password) (đếm byte) chứ không phải utf8.RuneCountInString.

Các phiên bản golang.org/x/crypto gần đây đã trả về lỗi khi mật khẩu quá 72 byte; các phiên bản cũ thì cắt bớt trong im lặng. Ta kiểm tra tường minh để hành vi giống nhau ở mọi phiên bản, và để thông báo lỗi là tiếng Việt của mình.

Có một mẹo phổ biến: băm SHA-256 trước rồi mới đưa vào bcrypt để bỏ giới hạn 72 byte. Nó có hiệu quả, nhưng phải mã hoá base64 kết quả SHA-256 trước, vì bcrypt dừng ở byte 0x00 đầu tiên và digest nhị phân rất dễ chứa byte đó. Làm sai bước này thì entropy thực tế sụt thảm hại mà không có gì báo lỗi. Với ứng dụng thông thường, đặt giới hạn 72 byte đơn giản và an toàn hơn nhiều.

7.2 ⚠️ Burn — vì sao phải đốt thời gian một cách cố ý

Cách viết đăng nhập tự nhiên nhất có một lỗ hổng:

u, err := repo.FindByEmail(ctx, email)
if err != nil {
    return ErrInvalidCredentials   // ← trả về sau ~2ms
}
if err := password.Verify(u.PasswordHash, plain); err != nil {
    return ErrInvalidCredentials   // ← trả về sau ~250ms
}

Cả hai nhánh trả cùng một thông báo lỗi, nên nhìn qua thì kín. Nhưng chúng khác nhau hơn một trăm lần về thời gian, và thời gian thì đo được từ bên ngoài:

Thời gian phản hồi Kẻ tấn công suy ra
~2ms Email này chưa đăng ký
~250ms Email này đã đăng ký, chỉ sai mật khẩu

Với chênh lệch lớn như vậy, không cần thống kê tinh vi gì — một vòng lặp curl là đủ để lọc ra danh sách email có thật trong hệ thống. Danh sách đó là nguyên liệu cho email lừa đảo nhắm đúng đối tượng, hoặc để thử mật khẩu rò rỉ từ nơi khác.

Burn làm nhánh "không tìm thấy" tốn đúng lượng thời gian như nhánh "sai mật khẩu". Cả hai cùng ~250ms, và kênh rò rỉ đóng lại.

Đây không phải cân bằng thời gian tuyệt đối — truy vấn DB vẫn lệch vài mili giây. Nhưng nó biến khoảng cách từ 100 lần xuống còn vài phần trăm, tức là từ "đo bằng mắt" thành "phải có hàng nghìn mẫu và mạng cực ổn định".


8. platform/token — JWT

Tạo internal/platform/token/token.go:

// Package token cấp và kiểm tra JSON Web Token.
//
// Package này chỉ biết "chủ thể" là một chuỗi. Chuỗi đó là user ID hay
// thứ gì khác là việc của module gọi tới nó.
package token

import (
	"errors"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)

var ErrInvalidToken = errors.New("token không hợp lệ")

// MinSecretLen — khoá HS256 ngắn hơn 32 byte làm yếu chữ ký.
const MinSecretLen = 32

type Issuer struct {
	secret []byte
	ttl    time.Duration
	issuer string
}

func NewIssuer(secret string, ttl time.Duration, issuerName string) (*Issuer, error) {
	if len(secret) < MinSecretLen {
		return nil, fmt.Errorf("token: secret phải dài ít nhất %d byte", MinSecretLen)
	}
	if ttl <= 0 {
		return nil, errors.New("token: ttl phải lớn hơn 0")
	}
	if issuerName == "" {
		return nil, errors.New("token: thiếu tên issuer")
	}
	return &Issuer{secret: []byte(secret), ttl: ttl, issuer: issuerName}, nil
}

// Issue nhận now làm tham số thay vì gọi time.Now() bên trong —
// nhờ vậy test kiểm tra được token hết hạn mà không phải chờ thật.
func (i *Issuer) Issue(subject string, now time.Time) (string, time.Time, error) {
	if subject == "" {
		return "", time.Time{}, errors.New("token: thiếu subject")
	}

	expiresAt := now.Add(i.ttl)
	claims := jwt.RegisteredClaims{
		Subject:   subject,
		Issuer:    i.issuer,
		IssuedAt:  jwt.NewNumericDate(now),
		NotBefore: jwt.NewNumericDate(now),
		ExpiresAt: jwt.NewNumericDate(expiresAt),
		ID:        uuid.NewString(), // để sau này thu hồi từng token nếu cần
	}

	signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
	if err != nil {
		return "", time.Time{}, fmt.Errorf("token: ký thất bại: %w", err)
	}
	return signed, expiresAt, nil
}

// Verify kiểm tra chữ ký, hạn dùng và issuer. Trả về subject nếu hợp lệ.
func (i *Issuer) Verify(raw string) (string, error) {
	var claims jwt.RegisteredClaims

	_, err := jwt.ParseWithClaims(raw, &claims,
		func(*jwt.Token) (any, error) { return i.secret, nil },
		// Ba tuỳ chọn dưới đây là phần quan trọng nhất của cả file. Xem §8.3.
		jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
		jwt.WithIssuer(i.issuer),
		jwt.WithExpirationRequired(),
	)
	if err != nil {
		return "", fmt.Errorf("%w: %v", ErrInvalidToken, err)
	}
	if claims.Subject == "" {
		return "", fmt.Errorf("%w: thiếu subject", ErrInvalidToken)
	}
	return claims.Subject, nil
}

8.1 Vì sao JWT nằm ở platform chứ không ở identity

Phép thử của ARCHITECTURE.md §4.5: chép package này sang một dự án Go hoàn toàn khác, nó có chạy được không?

token chỉ biết ký một chuỗi và kiểm tra lại chuỗi đó. Nó không biết "user" là gì. → platform.

Đổi lại, identity là nơi quyết định cái gì được đặt vào subject (ở đây là user.ID), và tầng transport là nơi quyết định endpoint nào cần token. Ranh giới này giữ cho service của identity không phải import thư viện JWT nào — nên test nghiệp vụ chạy mà không cần biết JWT tồn tại.

8.2 Vì sao chỉ dùng RegisteredClaims

Bạn sẽ muốn nhét thêm vào token: email, username, vai trò, avatar...

Đừng, ít nhất là chưa. Ba lý do:

  1. Token không cập nhật được. Người dùng đổi username lúc 10:00, token cấp lúc 9:00 vẫn còn hiệu lực tới 9:00 hôm sau và vẫn mang tên cũ. Mọi dữ liệu đặt trong token đều là bản chụp có thể đã lỗi thời.
  2. Token nằm ở trình duyệt. Nội dung JWT chỉ được base64, không mã hoá — ai cầm token đều đọc được mọi claim bằng mắt thường.
  3. Token đi kèm mọi request. Mỗi claim thêm vào là vài chục byte nhân với mọi lần gọi API.

Cần thông tin người dùng thì gọi GET /me. Chỉ đưa vào token thứ bắt buộc phải có để xác thực.

8.3 ⚠️ Hai lỗ hổng JWT kinh điển

Lỗ hổng 1 — alg: none. Đặc tả JWT có thuật toán none, nghĩa là "token này không cần chữ ký". Một thư viện ngây thơ sẽ chấp nhận:

{"alg":"none","typ":"JWT"}.{"sub":"id-cua-admin"}.

Kẻ tấn công tự viết claim, bỏ trống phần chữ ký, và đăng nhập thành bất kỳ ai.

Lỗ hổng 2 — nhầm lẫn thuật toán. Nguy hiểm hơn vì tinh vi hơn. Nếu server dùng RS256 (khoá công khai + khoá riêng), kẻ tấn công đổi header thành HS256 rồi ký bằng chính khoá công khai — thứ ai cũng lấy được. Server nào chỉ nhìn header để chọn cách kiểm tra sẽ dùng khoá công khai làm khoá HMAC, và chữ ký khớp.

Cả hai đều bị chặn bởi đúng một dòng:

jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})

Dòng này nói: "tôi chỉ chấp nhận HS256, bất kể header của token ghi gì." Header do kẻ tấn công kiểm soát, nên không bao giờ để header quyết định cách xác minh.

jwt.WithExpirationRequired() đóng nốt cái bẫy thứ ba: nếu thiếu nó, một token không có trường exp sẽ được coi là hợp lệ vĩnh viễn.

8.4 Cái giá của JWT: không thu hồi được

JWT là không trạng thái — server không lưu gì cả, chỉ kiểm chữ ký. Đó là ưu điểm (không cần tra DB mỗi request) và cũng là nhược điểm:

Bạn muốn Với phiên lưu ở DB Với JWT
Đăng xuất ngay lập tức Xoá dòng phiên → xong Không làm được — token vẫn hợp lệ tới lúc hết hạn
Khoá tài khoản vi phạm Xoá phiên → xong Không làm được
Xem ai đang đăng nhập SELECT là ra Không có gì để xem

Cách xử lý theo mức độ:

  1. TTL ngắn — 15 phút cho access token, cộng refresh token lưu ở DB. Cửa sổ rủi ro co lại còn 15 phút.
  2. Danh sách đen — lưu jti của token bị thu hồi vào Redis tới khi nó hết hạn. Đây là lý do trường ID có mặt trong claim ở trên.
  3. Chấp nhận — với dự án cộng đồng, TTL 24 giờ thường là đủ.

Tôi chọn cách 3 cho bước này và ghi rõ ra đây, vì đó là một quyết định chứ không phải một thiếu sót. Phụ lục có mẫu migration cho refresh token khi bạn cần nâng lên cách 1.


9. platform/outbox/writer.go

Bước 3 đã sinh sẵn hàm EnqueueEvent. Việc còn lại là bọc nó sao cho chỗ gọi chỉ cần nghĩ bằng contracts.Envelope.

Tạo internal/platform/outbox/writer.go:

// Package outbox ghi event vào bảng outbox và đẩy chúng lên broker.
package outbox

import (
	"context"
	"fmt"

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

	"github.com/yourname/community/internal/contracts"
	"github.com/yourname/community/internal/platform/outbox/gen"
	"github.com/yourname/community/internal/platform/requestid"
)

// Writer ghi event vào bảng outbox.
//
// Hiện chưa có trường nào, nhưng đây là kiểu mà service phụ thuộc vào —
// giữ nó là struct để sau này thêm logger hay metric mà không phải sửa
// chữ ký ở mọi chỗ gọi.
type Writer struct{}

func NewWriter() *Writer { return &Writer{} }

// Write ghi envelope vào outbox TRONG transaction đang mở.
//
// Tham số tx là bắt buộc và đó là chủ đích: không có transaction thì
// không gọi được hàm này. Luật 3 được trình biên dịch canh giữ,
// không phải bằng một dòng comment (xem bước 2 §4.3).
func (w *Writer) Write(ctx context.Context, tx pgx.Tx, topic string, e contracts.Envelope) error {
	if topic == "" {
		return fmt.Errorf("outbox: thiếu topic (%s)", e.EventType)
	}
	// Kiểm tra ở BIÊN GIỚI ghi. Một envelope hỏng lọt vào bảng sẽ làm
	// relay thất bại mãi mãi, và lúc đó rất khó lần ra ai đã ghi nó.
	if err := e.Validate(); err != nil {
		return fmt.Errorf("outbox: %w", err)
	}

	// Cột event_id kiểu UUID. Envelope giữ nó dạng chuỗi để không ràng buộc
	// consumer vào một thư viện UUID cụ thể, nên phải đổi kiểu ở đây.
	eventID, err := uuid.Parse(e.EventID)
	if err != nil {
		return fmt.Errorf("outbox: event_id không phải UUID (%q): %w", e.EventID, err)
	}

	// Nếu chỗ gọi chưa gắn correlation, lấy request ID đang có trong context.
	if e.CorrelationID == "" {
		e.CorrelationID = requestid.From(ctx)
	}
	var correlation *string
	if e.CorrelationID != "" {
		correlation = &e.CorrelationID
	}

	return gen.New(tx).EnqueueEvent(ctx, gen.EnqueueEventParams{
		EventID:       eventID,
		EventType:     e.EventType,
		Topic:         topic,
		AggregateID:   e.AggregateID,
		CorrelationID: correlation,
		Payload:       []byte(e.Payload),
		OccurredAt:    e.OccurredAt,
	})
}

gen.New(tx) — đây là chỗ mảnh ghép DBTX từ bước 3 §10.2 phát huy tác dụng. Cùng một hàm EnqueueEvent chạy được với *pgxpool.Pool (ngoài transaction) hoặc pgx.Tx (trong transaction). Ở đây ta bắt buộc dùng vế thứ hai.


10. contracts/v1/user_events.go

Tạo internal/contracts/v1/user_events.go:

package v1

import "time"

const (
	// TypeUserRegistered được phát khi một tài khoản mới được tạo THÀNH CÔNG
	// (tức là transaction đã commit).
	TypeUserRegistered = "identity.user.registered.v1"
)

// UserRegistered — dữ liệu tối thiểu để consumer làm việc của mình
// mà KHÔNG phải truy vấn ngược sang bảng users của module identity.
//
// ⚠️ TUYỆT ĐỐI KHÔNG thêm PasswordHash vào struct này. Xem tài liệu §10.1.
type UserRegistered struct {
	UserID       string    `json:"user_id"`
	Username     string    `json:"username"`
	Name         string    `json:"name"`
	Email        string    `json:"email"`
	RegisteredAt time.Time `json:"registered_at"`
}

Chỉ một hằng số, dù cây thư mục trong ARCHITECTURE.md có nhắc tới on_user_deleted.go. Lý do giống như ở bước 2 §5.4: một event chưa ai phát và chưa ai nghe là code chết — không có test nào chạm tới, không có gì chứng minh nó đúng, mà vẫn phải giữ tương thích ngược.

10.1 ⚠️ Cái gì được phép nằm trong payload

Payload event có vòng đời rất dài:

bảng outbox (giữ 7 ngày)  →  Kafka (giữ 7 ngày)  →  log của mọi consumer
                                                  →  DLQ nếu xử lý lỗi
                                                  →  bản sao lưu database

Nghĩa là mọi thứ bạn đặt vào đây sẽ được nhân bản ra nhiều nơi mà bạn không kiểm soát trực tiếp, và tồn tại lâu hơn bạn tưởng.

Trường Quyết định Vì sao
UserID ✅ Bắt buộc Khoá để consumer làm việc
Username, Name ✅ Có Vốn đã công khai trên profile
Email ⚠️ Có, cân nhắc Consumer gửi email chào mừng cần nó. Nhưng đây là dữ liệu cá nhân — nếu bạn phải tuân thủ quy định về xoá dữ liệu, hãy nhớ nó đang nằm trong Kafka
RegisteredAt ✅ Có Consumer thống kê cần mốc thời gian
PasswordHash Không bao giờ Xem dưới
Mật khẩu gốc Không bao giờ Không tồn tại ở bất kỳ đâu sau khi băm

Về PasswordHash: bcrypt không thể đảo ngược, nên phản xạ đầu tiên là "để vào cũng chẳng sao". Nhưng bạn vừa nhân bản toàn bộ hash mật khẩu của mọi người dùng sang Kafka, sang log của consumer, sang DLQ — những nơi gần như chắc chắn có quyền truy cập lỏng hơn bảng users. Từ chỗ một nơi cần bảo vệ, bạn tạo ra năm.

Nguyên tắc: payload chỉ chứa thứ consumer thật sự cần để làm việc của nó. Consumer nào cũng không cần hash mật khẩu.

§17.4 có một test giữ nguyên tắc này thay bạn, cho ngày có người thêm trường vào struct mà quên đọc đoạn này.


11. identity/domain

Tạo internal/modules/identity/domain/errors.go:

// Package domain chứa thực thể và quy tắc nghiệp vụ của module identity.
//
// Package này không import DB, không import HTTP, không import JWT.
// Nó là phần có thể suy luận đúng/sai mà không cần chạy thứ gì.
package domain

import "errors"

var (
	// ErrInvalidInput — dữ liệu người dùng gửi lên không hợp lệ.
	ErrInvalidInput = errors.New("dữ liệu không hợp lệ")

	ErrEmailTaken    = errors.New("email đã được sử dụng")
	ErrUsernameTaken = errors.New("username đã được sử dụng")
	ErrUserNotFound  = errors.New("không tìm thấy người dùng")

	// ErrInvalidCredentials cố ý KHÔNG phân biệt "sai email" với "sai mật khẩu".
	// Xem tài liệu §7.2 — cùng lý do với password.Burn.
	ErrInvalidCredentials = errors.New("email hoặc mật khẩu không đúng")
)

Tạo internal/modules/identity/domain/user.go:

package domain

import (
	"fmt"
	"net/mail"
	"regexp"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/google/uuid"
)

// User là thực thể nghiệp vụ. Không có tag `db` hay `json`:
// cách lưu trữ và cách hiển thị là việc của tầng khác.
type User struct {
	ID           uuid.UUID
	Name         string
	Username     string
	Email        string
	PasswordHash string
	AvatarURL    *string
	Reputation   int32
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

// usernameRe áp dụng SAU khi đã chuẩn hoá chữ thường.
// Chỉ cho phép a-z, 0-9 và gạch dưới để username luôn an toàn trong URL.
var usernameRe = regexp.MustCompile(`^[a-z0-9_]{3,30}$`)

const (
	MinPasswordLen = 8

	// MaxPasswordLen tính bằng BYTE, không phải ký tự — đây là giới hạn
	// của bcrypt (xem tài liệu §7.1).
	MaxPasswordLen = 72
)

// NormalizeEmail — bảng users có UNIQUE phân biệt hoa thường, nên mọi email
// phải đi qua đây trước khi chạm DB (bước 3 §3.3).
func NormalizeEmail(s string) string {
	return strings.ToLower(strings.TrimSpace(s))
}

func NormalizeUsername(s string) string {
	return strings.ToLower(strings.TrimSpace(s))
}

// Registration là dữ liệu thô cho một lần đăng ký.
type Registration struct {
	Name     string
	Username string
	Email    string
	Password string
}

// Validate kiểm tra dữ liệu đầu vào, không chạm tới DB.
// Những thứ chỉ database biết (email đã tồn tại chưa) được xử lý ở §12.
func (r Registration) Validate() error {
	name := strings.TrimSpace(r.Name)
	if n := utf8.RuneCountInString(name); n < 1 || n > 255 {
		return fmt.Errorf("%w: name phải từ 1 đến 255 ký tự", ErrInvalidInput)
	}

	if !usernameRe.MatchString(NormalizeUsername(r.Username)) {
		return fmt.Errorf("%w: username chỉ gồm a-z, 0-9, _ và dài 3–30 ký tự", ErrInvalidInput)
	}

	email := NormalizeEmail(r.Email)
	addr, err := mail.ParseAddress(email)
	// So sánh addr.Address với email để loại dạng `Tên <a@b.com>` —
	// mail.ParseAddress chấp nhận dạng đó, nhưng ta chỉ muốn địa chỉ trần.
	if err != nil || addr.Address != email {
		return fmt.Errorf("%w: email không đúng định dạng", ErrInvalidInput)
	}

	if n := len(r.Password); n < MinPasswordLen || n > MaxPasswordLen {
		return fmt.Errorf("%w: mật khẩu phải từ %d đến %d byte",
			ErrInvalidInput, MinPasswordLen, MaxPasswordLen)
	}

	return nil
}

// NewUser dựng thực thể từ dữ liệu đã hợp lệ và hash đã tính sẵn.
//
// ID do ứng dụng sinh chứ không để DEFAULT gen_random_uuid() —
// service cần ID ngay để đặt vào AggregateID của event, trong cùng
// transaction với lệnh INSERT (bước 3 §3.1).
func NewUser(r Registration, passwordHash string) User {
	return User{
		ID:           uuid.New(),
		Name:         strings.TrimSpace(r.Name),
		Username:     NormalizeUsername(r.Username),
		Email:        NormalizeEmail(r.Email),
		PasswordHash: passwordHash,
	}
}

Vì sao dùng net/mail thay vì regex email?

Vì regex email đúng theo RFC 5322 dài hơn 6000 ký tự, và mọi regex ngắn hơn đều từ chối sai một số địa chỉ hợp lệ. mail.ParseAddress nằm trong thư viện chuẩn, được viết đúng theo đặc tả, và bạn không phải bảo trì nó.

Nó vẫn không kiểm tra được hộp thư có tồn tại thật hay không — chỉ có gửi một email xác nhận mới làm được điều đó.


12. identity/repository

Bước 3 đã sinh CreateUser, GetUserByID, GetUserByEmail. Không cần thêm query nào, không cần chạy lại sqlc generate. Việc của tầng này là dịch giữa hai thế giới:

gen.User (kiểu của database)  ⇄  domain.User (kiểu của nghiệp vụ)
lỗi Postgres 23505            →  domain.ErrEmailTaken
pgx.ErrNoRows                 →  domain.ErrUserNotFound

12.1 Thêm một hàm vào platform/postgres/errors.go

Để phân biệt "trùng email" với "trùng username", ta cần biết ràng buộc nào bị vi phạm. Thêm vào cuối internal/platform/postgres/errors.go:

// ConstraintName trả về tên ràng buộc bị vi phạm.
// Trả "" nếu err không phải lỗi đến từ Postgres.
func ConstraintName(err error) string {
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) {
		return pgErr.ConstraintName
	}
	return ""
}

12.2 repository/postgres.go

// Package repository lưu trữ user trong PostgreSQL.
package repository

import (
	"context"
	"errors"
	"fmt"

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

	"github.com/yourname/community/internal/modules/identity/domain"
	"github.com/yourname/community/internal/modules/identity/repository/gen"
	"github.com/yourname/community/internal/platform/postgres"
)

// Tên ràng buộc UNIQUE trong migration 000001. Nếu đổi tên trong migration
// thì phải đổi ở đây — hai hằng số này là điểm nối duy nhất giữa hai file.
const (
	constraintEmail    = "users_email_key"
	constraintUsername = "users_username_key"
)

type Postgres struct {
	pool *pgxpool.Pool
}

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

// Insert ghi user mới TRONG transaction do service mở.
func (r *Postgres) Insert(ctx context.Context, tx pgx.Tx, u domain.User) (domain.User, error) {
	row, err := gen.New(tx).CreateUser(ctx, gen.CreateUserParams{
		ID:           u.ID,
		Name:         u.Name,
		Username:     u.Username,
		Email:        u.Email,
		PasswordHash: u.PasswordHash,
		AvatarUrl:    u.AvatarURL,
	})
	if err != nil {
		if postgres.IsUniqueViolation(err) {
			switch postgres.ConstraintName(err) {
			case constraintEmail:
				return domain.User{}, domain.ErrEmailTaken
			case constraintUsername:
				return domain.User{}, domain.ErrUsernameTaken
			}
		}
		return domain.User{}, fmt.Errorf("identity: tạo user: %w", err)
	}
	return toDomain(row), nil
}

// FindByEmail đọc ngoài transaction — đăng nhập không ghi gì cả.
func (r *Postgres) FindByEmail(ctx context.Context, email string) (domain.User, error) {
	row, err := gen.New(r.pool).GetUserByEmail(ctx, email)
	return mapRow(row, err)
}

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

func mapRow(row gen.User, err error) (domain.User, error) {
	switch {
	case errors.Is(err, pgx.ErrNoRows):
		return domain.User{}, domain.ErrUserNotFound
	case err != nil:
		return domain.User{}, fmt.Errorf("identity: đọc user: %w", err)
	}
	return toDomain(row), nil
}

func toDomain(u gen.User) domain.User {
	return domain.User{
		ID:           u.ID,
		Name:         u.Name,
		Username:     u.Username,
		Email:        u.Email,
		PasswordHash: u.PasswordHash,
		AvatarURL:    u.AvatarUrl,
		Reputation:   u.Reputation,
		CreatedAt:    u.CreatedAt,
		UpdatedAt:    u.UpdatedAt,
	}
}

AvatarUrl hay AvatarURL?

sqlc chỉ viết hoa toàn bộ với idID. Các từ viết tắt khác giữ dạng thường: avatar_urlAvatarUrl. Kiểu domain.User của ta dùng AvatarURL đúng quy ước Go, và hàm toDomain là chỗ duy nhất phải biết sự khác biệt đó.

Muốn thống nhất, sqlc.yamlrename: — nhưng đổi một tên ở đó sinh lại toàn bộ gen/ của cả bốn package. Với một trường, dịch tay tại toDomain rẻ hơn.

12.3 ⚠️ Không kiểm tra trước rồi mới ghi

Đoạn code mà gần như ai cũng viết ở lần đầu:

// ❌ SAI — có khoảng trống giữa hai lệnh
if _, err := repo.FindByEmail(ctx, email); err == nil {
    return domain.ErrEmailTaken
}
repo.Insert(ctx, tx, user)

Hai người cùng đăng ký một email trong cùng một phần nghìn giây: cả hai đều thấy "chưa có ai", cả hai đều INSERT, và một người nhận lỗi 500 khó hiểu từ database thay vì thông báo "email đã được dùng".

Cách đúng là cứ ghi, và dịch lỗi của database thành lỗi nghiệp vụ — chính là những gì Insert ở trên làm. Ràng buộc UNIQUE là một khoá thật do database giữ; đoạn if trong Go thì không.

Đây cũng chính là nguyên tắc đã dùng cho MarkConsumedbước 3 §9.2: để database xử lý tranh chấp đồng thời. Cùng một bài học, xuất hiện lần thứ hai ở một chỗ hoàn toàn khác — đó là dấu hiệu nó đáng nhớ.


13. identity/service

13.1 Khai báo phụ thuộc

Tạo internal/modules/identity/service/service.go:

// Package service chứa nghiệp vụ của module identity.
//
// Nó không biết HTTP, không biết Kafka, không biết sqlc.
// Mọi thứ nó cần được khai báo bằng interface ngay trong file này.
package service

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

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

	"github.com/yourname/community/internal/contracts"
	"github.com/yourname/community/internal/modules/identity/domain"
)

// Transactor chạy một hàm trong transaction. *postgres.TxManager thoả interface này.
type Transactor interface {
	Do(ctx context.Context, fn func(tx pgx.Tx) error) error
}

// Repository — đúng ba việc service cần, không hơn.
type Repository interface {
	Insert(ctx context.Context, tx pgx.Tx, u domain.User) (domain.User, error)
	FindByEmail(ctx context.Context, email string) (domain.User, error)
	FindByID(ctx context.Context, id uuid.UUID) (domain.User, error)
}

// EventWriter ghi event vào outbox. *outbox.Writer thoả interface này.
type EventWriter interface {
	Write(ctx context.Context, tx pgx.Tx, topic string, e contracts.Envelope) error
}

// TokenIssuer cấp token. *token.Issuer thoả interface này.
type TokenIssuer interface {
	Issue(subject string, now time.Time) (string, time.Time, error)
}

type Service struct {
	tx     Transactor
	repo   Repository
	events EventWriter
	tokens TokenIssuer
	log    *slog.Logger

	// now là hàm để test kiểm soát thời gian mà không phải chờ thật.
	now func() time.Time
}

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

// Profile trả về thông tin người dùng cho endpoint GET /me.
func (s *Service) Profile(ctx context.Context, id uuid.UUID) (domain.User, error) {
	return s.repo.FindByID(ctx, id)
}

Bốn interface này đều do bên gọi định nghĩa

Không phải outbox khai EventWriter rồi service dùng nhờ, mà ngược lại: service khai đúng cái nó cần, và *outbox.Writer tình cờ thoả mãn.

Khác biệt tưởng nhỏ nhưng đổi chiều phụ thuộc: service không import outbox chỉ để lấy interface; và fake trong test chỉ cần cài đúng một phương thức thay vì cả gói.

Vì sao Transactor mà không dùng thẳng *postgres.TxManager? Vì kiểu cụ thể bắt mọi test của service phải có PostgreSQL đang chạy. Với interface, §17.3 chạy hết nghiệp vụ đăng ký/đăng nhập trong vài mili giây, không cần Docker.

Đổi lại, ta mất hàm generic postgres.DoValue — Go không cho method có tham số kiểu, nên DoValue là hàm cấp package nhận *TxManager cụ thể. Giải pháp: dùng Do và gán kết quả vào biến bên ngoài closure, đúng như register.go dưới đây.

13.2 register.go — nơi Luật 3 thành code

Tạo internal/modules/identity/service/register.go:

package service

import (
	"context"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5"

	"github.com/yourname/community/internal/contracts"
	v1 "github.com/yourname/community/internal/contracts/v1"
	"github.com/yourname/community/internal/modules/identity/domain"
	"github.com/yourname/community/internal/platform/password"
)

type AuthResult struct {
	User      domain.User
	Token     string
	ExpiresAt time.Time
}

func (s *Service) Register(ctx context.Context, in domain.Registration) (AuthResult, error) {
	// 1. Kiểm tra dữ liệu — không chạm DB, không tốn gì.
	if err := in.Validate(); err != nil {
		return AuthResult{}, err
	}

	// 2. Băm mật khẩu NGOÀI transaction.
	//    bcrypt tốn ~250ms CPU; làm việc này trong transaction là giữ một
	//    kết nối DB rảnh rỗi suốt 250ms cho mỗi lượt đăng ký (xem §3).
	hash, err := password.Hash(in.Password)
	if err != nil {
		return AuthResult{}, fmt.Errorf("identity: băm mật khẩu: %w", err)
	}

	user := domain.NewUser(in, hash)

	// 3. Ghi user và ghi event trong CÙNG một transaction.
	var created domain.User
	err = s.tx.Do(ctx, func(tx pgx.Tx) error {
		saved, err := s.repo.Insert(ctx, tx, user)
		if err != nil {
			return err // ErrEmailTaken / ErrUsernameTaken đi thẳng ra ngoài
		}

		event, err := contracts.NewEnvelope(
			v1.TypeUserRegistered,
			saved.ID.String(), // AggregateID → Kafka partition key
			v1.UserRegistered{
				UserID:       saved.ID.String(),
				Username:     saved.Username,
				Name:         saved.Name,
				Email:        saved.Email,
				RegisteredAt: saved.CreatedAt,
			},
		)
		if err != nil {
			return fmt.Errorf("identity: dựng event: %w", err)
		}

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

		created = saved
		return nil
	})
	if err != nil {
		return AuthResult{}, err
	}

	// 4. Cấp token SAU khi commit. Token không phải dữ liệu cần bảo toàn,
	//    không có lý do gì để nó kéo dài thời gian giữ transaction.
	now := s.now()
	tok, expiresAt, err := s.tokens.Issue(created.ID.String(), now)
	if err != nil {
		// Tài khoản ĐÃ được tạo thành công. Người dùng chỉ cần đăng nhập lại.
		return AuthResult{}, fmt.Errorf("identity: cấp token: %w", err)
	}

	s.log.Info("đăng ký thành công",
		"user_id", created.ID.String(),
		"username", created.Username)

	return AuthResult{User: created, Token: tok, ExpiresAt: expiresAt}, nil
}

Đọc lại bước 3 một lượt: repo.Inserts.events.Write nhận cùng một tx. Không có cách nào để một cái thành công còn cái kia thất bại. Nếu Write lỗi, TxManager.Do rollback và dòng users cũng biến mất.

Đây chính là điều mà test TestLoiNghiepVuKeoTheoOutboxRollback ở bước 3 đã chứng minh trước bằng SQL trần — giờ nó là code nghiệp vụ thật.

13.3 login.go

Tạo internal/modules/identity/service/login.go:

package service

import (
	"context"
	"errors"
	"fmt"

	"github.com/yourname/community/internal/modules/identity/domain"
	"github.com/yourname/community/internal/platform/password"
)

// Login KHÔNG mở transaction: nó không ghi gì cả.
func (s *Service) Login(ctx context.Context, email, plain string) (AuthResult, error) {
	normalized := domain.NormalizeEmail(email)

	user, err := s.repo.FindByEmail(ctx, normalized)
	if err != nil {
		if errors.Is(err, domain.ErrUserNotFound) {
			// Đốt đúng lượng thời gian như một lần kiểm mật khẩu thật,
			// để bên ngoài không đo được email nào có tồn tại (§7.2).
			password.Burn(plain)
			return AuthResult{}, domain.ErrInvalidCredentials
		}
		return AuthResult{}, err
	}

	if err := password.Verify(user.PasswordHash, plain); err != nil {
		if errors.Is(err, password.ErrMismatch) {
			// Cùng một lỗi với nhánh "không tìm thấy" — có chủ đích.
			return AuthResult{}, domain.ErrInvalidCredentials
		}
		return AuthResult{}, fmt.Errorf("identity: kiểm mật khẩu: %w", err)
	}

	tok, expiresAt, err := s.tokens.Issue(user.ID.String(), s.now())
	if err != nil {
		return AuthResult{}, fmt.Errorf("identity: cấp token: %w", err)
	}

	s.log.Info("đăng nhập thành công", "user_id", user.ID.String())
	return AuthResult{User: user, Token: tok, ExpiresAt: expiresAt}, nil
}

13.4 Vì sao đăng nhập không phát event

Câu hỏi hợp lý: đăng ký phát event, sao đăng nhập lại không?

Luật 2: event tồn tại để module khác phản ứng. Hiện chưa có module nào cần biết ai vừa đăng nhập.

Ngày bạn cần ghi nhật ký đăng nhập hay phát hiện truy cập bất thường, identity.user.logged_in.v1 sẽ được thêm vào — cùng lúc với consumer đầu tiên của nó. Phát một event không ai nghe chỉ tạo ra rác trong Kafka và một hợp đồng phải giữ tương thích mãi mãi.

Phép thử trước khi thêm event mới: có ai đang chờ nghe nó không? Không → chưa thêm.


14. identity/transport/http

14.1 DTO

Tạo internal/modules/identity/transport/http/dto.go:

package http

import (
	"time"

	"github.com/yourname/community/internal/modules/identity/domain"
)

type RegisterRequest struct {
	Name     string `json:"name"`
	Username string `json:"username"`
	Email    string `json:"email"`
	Password string `json:"password"`
}

type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

// UserResponse là hình dạng user mà thế giới bên ngoài được thấy.
//
// ⚠️ Struct này tồn tại CHÍNH VÌ domain.User có trường PasswordHash.
// Xem §14.2.
type UserResponse struct {
	ID         string  `json:"id"`
	Name       string  `json:"name"`
	Username   string  `json:"username"`
	Email      string  `json:"email"`
	AvatarURL  *string `json:"avatar_url"`
	Reputation int32   `json:"reputation"`
	CreatedAt  string  `json:"created_at"`
}

type AuthResponse struct {
	User      UserResponse `json:"user"`
	Token     string       `json:"token"`
	ExpiresAt string       `json:"expires_at"`
}

func newUserResponse(u domain.User) UserResponse {
	return UserResponse{
		ID:         u.ID.String(),
		Name:       u.Name,
		Username:   u.Username,
		Email:      u.Email,
		AvatarURL:  u.AvatarURL,
		Reputation: u.Reputation,
		CreatedAt:  u.CreatedAt.Format(time.RFC3339),
	}
}

14.2 ⚠️ Không bao giờ trả thẳng struct của tầng dưới

Đây là kiểu rò rỉ dữ liệu phổ biến nhất trong các API viết bằng Go:

// ❌ Trông tiện, và nó rò rỉ password_hash của mọi người dùng
httpx.JSON(w, 200, user)

Nếu domain.User (hoặc tệ hơn: gen.User sinh bởi sqlc) có tag json, hàm trên sẽ mã hoá mọi trường, kể cả PasswordHash. Không có lỗi nào, không có cảnh báo nào — chỉ là hash mật khẩu nằm trong response JSON.

Hai lớp phòng thủ trong dự án này:

  1. domain.User không có tag json nào. Nó sẽ được mã hoá thành {"ID":"...","PasswordHash":"$2a$12$..."} — xấu và sai rõ ràng, khiến người viết phải dừng lại.
  2. UserResponse liệt kê tường minh từng trường được phép ra ngoài. Thêm cột mới vào bảng users sẽ không tự động lộ ra API; ai đó phải chủ động thêm nó vào đây.

Cùng lý lẽ đó áp dụng cho chiều ngược lại: RegisterRequest là struct riêng chứ không phải domain.User, nên client không thể tự đặt reputation: 9999 cho mình.

14.3 Ánh xạ lỗi sang HTTP status

Tạo internal/modules/identity/transport/http/handler.go:

// Package http là tầng vào ra HTTP của module identity.
package http

import (
	"errors"
	"log/slog"
	"net/http"
	"time"

	"github.com/google/uuid"

	"github.com/yourname/community/internal/modules/identity/domain"
	"github.com/yourname/community/internal/modules/identity/service"
	"github.com/yourname/community/internal/platform/httpx"
	"github.com/yourname/community/internal/platform/httpx/middleware"
	"github.com/yourname/community/internal/platform/requestid"
)

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

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

func (h *Handler) handleRegister(w http.ResponseWriter, r *http.Request) {
	var req RegisterRequest
	if err := httpx.DecodeJSON(w, r, &req); err != nil {
		httpx.Fail(w, r, http.StatusBadRequest, "bad_request", err.Error())
		return
	}

	res, err := h.svc.Register(r.Context(), domain.Registration{
		Name:     req.Name,
		Username: req.Username,
		Email:    req.Email,
		Password: req.Password,
	})
	if err != nil {
		h.fail(w, r, err)
		return
	}

	httpx.JSON(w, http.StatusCreated, AuthResponse{
		User:      newUserResponse(res.User),
		Token:     res.Token,
		ExpiresAt: res.ExpiresAt.Format(time.RFC3339),
	})
}

func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
	var req LoginRequest
	if err := httpx.DecodeJSON(w, r, &req); err != nil {
		httpx.Fail(w, r, http.StatusBadRequest, "bad_request", err.Error())
		return
	}

	res, err := h.svc.Login(r.Context(), req.Email, req.Password)
	if err != nil {
		h.fail(w, r, err)
		return
	}

	httpx.JSON(w, http.StatusOK, AuthResponse{
		User:      newUserResponse(res.User),
		Token:     res.Token,
		ExpiresAt: res.ExpiresAt.Format(time.RFC3339),
	})
}

func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) {
	subject, ok := middleware.Subject(r.Context())
	if !ok {
		httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "chưa xác thực")
		return
	}

	id, err := uuid.Parse(subject)
	if err != nil {
		// Token hợp lệ về chữ ký nhưng subject không phải UUID —
		// chỉ xảy ra nếu secret bị dùng để ký thứ khác.
		httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "token không hợp lệ")
		return
	}

	user, err := h.svc.Profile(r.Context(), id)
	if err != nil {
		h.fail(w, r, err)
		return
	}

	httpx.JSON(w, http.StatusOK, newUserResponse(user))
}

// fail dịch lỗi nghiệp vụ sang mã HTTP.
//
// Hàm này nằm ở module, KHÔNG nằm ở platform/httpx: chỉ module mới
// biết lỗi của nó có nghĩa gì (xem §6.2).
func (h *Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
	switch {
	case errors.Is(err, domain.ErrInvalidInput):
		// An toàn khi lộ ra ngoài: đây là thông báo do ta tự viết.
		httpx.Fail(w, r, http.StatusUnprocessableEntity, "invalid_input", err.Error())

	case errors.Is(err, domain.ErrEmailTaken):
		httpx.Fail(w, r, http.StatusConflict, "email_taken", "email đã được sử dụng")

	case errors.Is(err, domain.ErrUsernameTaken):
		httpx.Fail(w, r, http.StatusConflict, "username_taken", "username đã được sử dụng")

	case errors.Is(err, domain.ErrInvalidCredentials):
		httpx.Fail(w, r, http.StatusUnauthorized, "invalid_credentials", "email hoặc mật khẩu không đúng")

	case errors.Is(err, domain.ErrUserNotFound):
		httpx.Fail(w, r, http.StatusNotFound, "not_found", "không tìm thấy người dùng")

	default:
		// Log ĐẦY ĐỦ cho mình, trả CHUNG CHUNG cho client.
		// err ở đây có thể chứa tên bảng, tên cột, đoạn SQL — thông tin
		// quý giá cho kẻ đang dò hệ thống.
		h.log.Error("lỗi không lường trước",
			"err", err,
			"method", r.Method,
			"path", r.URL.Path,
			"request_id", requestid.From(r.Context()))
		httpx.Fail(w, r, http.StatusInternalServerError, "internal", "lỗi hệ thống")
	}
}

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í