第 3 章:使用者註冊 / 登入(JWT)

第 3 章:使用者註冊 / 登入(JWT)

這一章在做什麼? 我們要做出三支 API:註冊、登入、以及一個「需要登入才能用」的 /me。註冊時用 bcrypt 把密碼雜湊後存起來;登入成功後簽發一張 JWT;之後帶著這張 token 的請求,會經過一個 中介層(middleware) 驗證身分。

為什麼這章份量最重? 因為我們會第一次把 handler → service → repository 三層架構完整串起來,這個骨架後面每一章都照抄。看懂這一章,後面就是不斷複製貼上同一套模式、換不同業務邏輯而已。


先看這章的資料流

POST /api/auth/register
   │
   ▼
[handler] AuthHandler.Register     ← 解析 JSON、做輸入驗證、回應
   │  呼叫
   ▼
[service] AuthService.Register     ← 業務邏輯:檢查重複、雜湊密碼、開交易建 user+account
   │  呼叫
   ▼
[repository] UserRepository/AccountRepository  ← 只負責跑 SQL
   │
   ▼
PostgreSQL

步驟 1:安裝 bcrypt 與 JWT 套件

go get golang.org/x/crypto/bcrypt
go get github.com/golang-jwt/jwt/v5
  • golang.org/x/crypto/bcrypt:密碼雜湊。bcrypt 會自動加鹽(salt),是業界存密碼的標準做法。
  • github.com/golang-jwt/jwt/v5:簽發與驗證 JWT。

🔁 JS 對照 bcrypt ≈ Node 的 bcrypt / bcryptjsgolang-jwtjsonwebtoken。用法你會很熟悉:雜湊、比對、簽 token、驗 token。


步驟 2:repository 層(只負責碰資料庫)

2-1 先定義一個共用的 DBTX 介面

我們希望 repository 的方法,既能在「一般查詢」時用 *sqlx.DB,也能在「交易中」用 *sqlx.Tx(註冊時要在交易裡同時建立 user 和 account)。這兩個型別剛好都有 Get / Select / Exec / QueryRowx 這些方法——這正是第 0 章講的 interface(鴨子型別) 的最佳使用場景。

建立 internal/repository/repository.go

// internal/repository/repository.go
package repository

import (
	"database/sql"

	"github.com/jmoiron/sqlx"
)

// DBTX 是 *sqlx.DB 與 *sqlx.Tx 共同擁有的方法子集。
// repository 的方法都接收 DBTX,所以同一個方法:
//   - 平常查詢時傳 *sqlx.DB
//   - 需要交易時傳 *sqlx.Tx
// 兩種情況都能用,不必寫兩套。
type DBTX interface {
	Get(dest interface{}, query string, args ...interface{}) error
	Select(dest interface{}, query string, args ...interface{}) error
	Exec(query string, args ...interface{}) (sql.Result, error)
	QueryRowx(query string, args ...interface{}) *sqlx.Row
}

🔁 JS 對照 這在動態語言根本不用想——JS 你傳 db 還是 tx 進去都能呼叫 .query()。Go 因為是靜態型別,要明確宣告「我接受任何有這些方法的東西」,這就是 interface 的用途。一旦定義好,*sqlx.DB*sqlx.Tx 都「自動符合」(不用寫 implements)。

2-2 UserRepository

建立 internal/repository/user_repository.go

// internal/repository/user_repository.go
package repository

import (
	"no-logic-trade/internal/model"
)

type UserRepository struct{}

func NewUserRepository() *UserRepository {
	return &UserRepository{}
}

// Create 新增一筆 user,回傳新的 id
func (r *UserRepository) Create(q DBTX, email, passwordHash string) (int64, error) {
	var id int64
	err := q.QueryRowx(
		`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id`,
		email, passwordHash,
	).Scan(&id)
	if err != nil {
		return 0, err
	}
	return id, nil
}

// GetByEmail 用 email 找 user(找不到會回傳 sql.ErrNoRows)
func (r *UserRepository) GetByEmail(q DBTX, email string) (*model.User, error) {
	var u model.User
	if err := q.Get(&u, `SELECT * FROM users WHERE email = $1`, email); err != nil {
		return nil, err
	}
	return &u, nil
}

// GetByID 用 id 找 user
func (r *UserRepository) GetByID(q DBTX, id int64) (*model.User, error) {
	var u model.User
	if err := q.Get(&u, `SELECT * FROM users WHERE id = $1`, id); err != nil {
		return nil, err
	}
	return &u, nil
}

重點:

  • repository 是 stateless(沒有任何欄位)——每個方法第一個參數收 DBTX,由呼叫者(service)決定要傳 db 還是 tx
  • INSERT ... RETURNING id:PostgreSQL 的語法,插入後直接回傳新 id。配 QueryRowx(...).Scan(&id) 把它讀進變數。
  • q.Get(&u, ...):sqlx 把單一列結果,依 struct 的 db tag 自動塞進 u。找不到資料時回傳 sql.ErrNoRows(這個錯誤等等 service 會特別處理)。
  • $1$2:PostgreSQL 的參數佔位符。永遠用佔位符帶入使用者輸入,不要用字串拼接——這是防 SQL injection 的基本功(sqlx 會安全地把值傳給資料庫)。

🔁 JS 對照

// node-postgres
const { rows } = await db.query(
  "SELECT * FROM users WHERE email = $1", [email]
);
const user = rows[0];

佔位符 $1 一模一樣。差別是 sqlx 的 Get 幫你「自動把那一列對應到 struct」,省掉手動取 rows[0] 再一欄欄搬。

2-3 AccountRepository

建立 internal/repository/account_repository.go

// internal/repository/account_repository.go
package repository

import (
	"no-logic-trade/internal/model"
)

type AccountRepository struct{}

func NewAccountRepository() *AccountRepository {
	return &AccountRepository{}
}

// Create 替某個 user 建立帳戶(初始餘額為 0),回傳新帳戶 id
func (r *AccountRepository) Create(q DBTX, userID int64) (int64, error) {
	var id int64
	err := q.QueryRowx(
		`INSERT INTO accounts (user_id) VALUES ($1) RETURNING id`,
		userID,
	).Scan(&id)
	if err != nil {
		return 0, err
	}
	return id, nil
}

// GetByUserID 用 user id 找帳戶
func (r *AccountRepository) GetByUserID(q DBTX, userID int64) (*model.Account, error) {
	var a model.Account
	if err := q.Get(&a, `SELECT * FROM accounts WHERE user_id = $1`, userID); err != nil {
		return nil, err
	}
	return &a, nil
}

步驟 3:service 層(業務邏輯 + JWT)

建立 internal/service/auth_service.go。這支檔案比較長,我們分段看,但它是一個完整的檔案。

3-1 型別、建構子、自訂錯誤

// internal/service/auth_service.go
package service

import (
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/jmoiron/sqlx"
	"golang.org/x/crypto/bcrypt"

	"no-logic-trade/internal/model"
	"no-logic-trade/internal/repository"
)

// 預先定義好的錯誤,讓 handler 可以用 errors.Is 判斷該回哪種 HTTP 狀態
var (
	ErrEmailTaken         = errors.New("email 已被註冊")
	ErrInvalidCredentials = errors.New("帳號或密碼錯誤")
)

// Claims 是我們放進 JWT 的內容
type Claims struct {
	UserID int64 `json:"uid"`
	jwt.RegisteredClaims
}

type AuthService struct {
	db        *sqlx.DB
	users     *repository.UserRepository
	accounts  *repository.AccountRepository
	jwtSecret string
}

func NewAuthService(
	db *sqlx.DB,
	users *repository.UserRepository,
	accounts *repository.AccountRepository,
	jwtSecret string,
) *AuthService {
	return &AuthService{db: db, users: users, accounts: accounts, jwtSecret: jwtSecret}
}
  • var ( ErrEmailTaken = ... ):把「業務上會發生的特定錯誤」定義成具名的 error 變數。等等 handler 用 errors.Is(err, service.ErrEmailTaken) 判斷,就能把不同錯誤對應到不同 HTTP 狀態碼(409 vs 500)。這是 Go 處理「可預期錯誤」的慣用法。
  • Claims:我們的 JWT 內容。UserID 是自訂欄位;內嵌的 jwt.RegisteredClaims 提供標準欄位(過期時間 exp、簽發時間 iat 等)。
  • AuthService 持有 db(用來開交易)、兩個 repository、以及 JWT 密鑰。
  • 依賴注入NewAuthService 把需要的東西從外面傳進來,而不是在內部自己 new。這讓 service 好測試、相依關係一目了然(第 6 步組裝時你會看到全貌)。

🔁 JS 對照 內嵌 jwt.RegisteredClaims 是 Go 的「組合」概念,類似把一個物件的欄位攤平進另一個。Claims 序列化成 JSON 後,會同時有 uidexpiat

3-2 註冊(在交易裡同時建立 user 與 account)

接在同一個檔案後面:

// Register 建立新使用者與其帳戶
func (s *AuthService) Register(email, password string) (*model.User, error) {
	// 1) 先檢查 email 是否已存在
	_, err := s.users.GetByEmail(s.db, email)
	if err == nil {
		return nil, ErrEmailTaken // 查得到 → 已被註冊
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return nil, err // 不是「查無資料」,那就是真的 DB 出錯
	}
	// 走到這代表 err == sql.ErrNoRows,也就是 email 沒被用過,繼續。

	// 2) 雜湊密碼
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return nil, fmt.Errorf("雜湊密碼失敗: %w", err)
	}

	// 3) 開一筆交易,同時建立 user 與 account(要嘛都成功,要嘛都不留)
	tx, err := s.db.Beginx()
	if err != nil {
		return nil, fmt.Errorf("開始交易失敗: %w", err)
	}
	defer tx.Rollback() // 關鍵慣用法,下面解釋

	userID, err := s.users.Create(tx, email, string(hash))
	if err != nil {
		return nil, fmt.Errorf("建立 user 失敗: %w", err)
	}

	if _, err := s.accounts.Create(tx, userID); err != nil {
		return nil, fmt.Errorf("建立 account 失敗: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("提交交易失敗: %w", err)
	}

	// 4) 回傳剛建立的 user
	return s.users.GetByID(s.db, userID)
}

兩個關鍵:

  1. email 重複檢查:先用 GetByEmail 查。

    • err == nil(查得到)→ 回 ErrEmailTaken
    • err 是其他錯誤(不是 sql.ErrNoRows)→ 真的出錯,往上拋。
    • errsql.ErrNoRows(查無)→ 代表沒被用過,繼續註冊。
    • errors.Is(err, sql.ErrNoRows) 是判斷「是不是某個特定錯誤」的標準寫法。
  2. defer tx.Rollback() 這個慣用法(重要)

    • 我們在「開交易的下一行」就 defer 一個 rollback。
    • 如果中間任何一步 return(出錯),函式結束前 defer 會執行 rollback,交易被取消——資料庫乾乾淨淨。
    • 如果一切順利,我們會先 tx.Commit()commit 之後,那個 deferred rollback 仍會執行,但對「已提交的交易」rollback 是無效操作(回傳一個我們忽略的錯誤),不會有任何影響。
    • 這個「defer rollback + 成功才 commit」的組合,是 Go 寫交易最乾淨、最不會漏掉 rollback 的寫法。第 6 章下單會再次大用。

🔁 JS 對照

const tx = await pool.connect();
try {
  await tx.query("BEGIN");
  const userId = await createUser(tx, ...);
  await createAccount(tx, userId);
  await tx.query("COMMIT");
} catch (e) {
  await tx.query("ROLLBACK");   // 你得記得在每個 catch 寫
  throw e;
} finally {
  tx.release();
}

Go 的 defer tx.Rollback() 等於把「忘記就慘了的 ROLLBACK」變成一行、自動、絕對會跑。你只要在成功路徑記得 Commit 就好。

3-3 登入與 JWT

接在同一個檔案後面:

// Login 驗證帳密,成功回傳一張 JWT
func (s *AuthService) Login(email, password string) (string, error) {
	user, err := s.users.GetByEmail(s.db, email)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return "", ErrInvalidCredentials // 找不到帳號(不明說是帳號還是密碼錯,較安全)
		}
		return "", fmt.Errorf("查詢使用者失敗: %w", err)
	}

	// 比對密碼:把使用者輸入的密碼跟資料庫的雜湊比對
	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
		return "", ErrInvalidCredentials // 密碼不符
	}

	return s.generateToken(user.ID)
}

// GetUserByID 給 /me 之類的地方用
func (s *AuthService) GetUserByID(id int64) (*model.User, error) {
	return s.users.GetByID(s.db, id)
}

// generateToken 簽發一張有效 24 小時的 JWT
func (s *AuthService) generateToken(userID int64) (string, error) {
	claims := Claims{
		UserID: userID,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := token.SignedString([]byte(s.jwtSecret))
	if err != nil {
		return "", fmt.Errorf("簽發 token 失敗: %w", err)
	}
	return signed, nil
}

// ParseToken 驗證一張 token,成功回傳其中的 userID
func (s *AuthService) ParseToken(tokenStr string) (int64, error) {
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
		// 確認簽章演算法真的是我們用的 HMAC,擋掉偽造攻擊
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("非預期的簽章方法: %v", t.Header["alg"])
		}
		return []byte(s.jwtSecret), nil
	})
	if err != nil {
		return 0, err
	}
	if !token.Valid {
		return 0, errors.New("token 無效")
	}
	return claims.UserID, nil
}
  • bcrypt.CompareHashAndPassword:比對成功回 nil,失敗回 error。我們把「找不到帳號」和「密碼錯」都回同一個 ErrInvalidCredentials,避免洩漏「這個 email 有沒有註冊過」。
  • generateToken:用 HS256(對稱式簽章,靠一把密鑰)簽出 token,內含 uid 與過期時間。
  • ParseToken:用同一把密鑰驗證簽章與有效期。那個 keyFunc(回傳密鑰的函式)裡特別檢查簽章演算法,是 JWT 的標準防呆。

🔁 JS 對照

const token = jwt.sign({ uid: userId }, secret, { expiresIn: "24h" });
const { uid } = jwt.verify(token, secret); // 驗證失敗會 throw

概念一致。Go 多了「明確檢查演算法」與「用回傳 error 取代 throw」。


步驟 4:handler 層(接 HTTP + 輸入驗證)

4-1 共用回應輔助

建立 internal/handler/handler.go

// internal/handler/handler.go
package handler

import "github.com/gin-gonic/gin"

// respondError 統一錯誤回應格式:{"error": "訊息"}
func respondError(c *gin.Context, status int, message string) {
	c.JSON(status, gin.H{"error": message})
}

統一錯誤格式,前端就能固定用 res.error 取訊息。

4-2 AuthHandler

建立 internal/handler/auth_handler.go

// internal/handler/auth_handler.go
package handler

import (
	"errors"
	"net/http"

	"github.com/gin-gonic/gin"

	"no-logic-trade/internal/service"
)

type AuthHandler struct {
	auth *service.AuthService
}

func NewAuthHandler(auth *service.AuthService) *AuthHandler {
	return &AuthHandler{auth: auth}
}

// 用 struct + binding tag 描述「合法的請求長怎樣」
type registerRequest struct {
	Email    string `json:"email" binding:"required,email"`
	Password string `json:"password" binding:"required,min=6"`
}

type loginRequest struct {
	Email    string `json:"email" binding:"required,email"`
	Password string `json:"password" binding:"required"`
}

// POST /api/auth/register
func (h *AuthHandler) Register(c *gin.Context) {
	var req registerRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		respondError(c, http.StatusBadRequest, err.Error())
		return
	}

	user, err := h.auth.Register(req.Email, req.Password)
	if err != nil {
		if errors.Is(err, service.ErrEmailTaken) {
			respondError(c, http.StatusConflict, "email 已被註冊")
			return
		}
		respondError(c, http.StatusInternalServerError, "註冊失敗")
		return
	}

	c.JSON(http.StatusCreated, gin.H{"user": user})
}

// POST /api/auth/login
func (h *AuthHandler) Login(c *gin.Context) {
	var req loginRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		respondError(c, http.StatusBadRequest, err.Error())
		return
	}

	token, err := h.auth.Login(req.Email, req.Password)
	if err != nil {
		if errors.Is(err, service.ErrInvalidCredentials) {
			respondError(c, http.StatusUnauthorized, "帳號或密碼錯誤")
			return
		}
		respondError(c, http.StatusInternalServerError, "登入失敗")
		return
	}

	c.JSON(http.StatusOK, gin.H{"token": token})
}

// GET /api/me(需登入)
func (h *AuthHandler) Me(c *gin.Context) {
	// userID 是中介層驗證 token 後放進 context 的
	userID := c.GetInt64("userID")

	user, err := h.auth.GetUserByID(userID)
	if err != nil {
		respondError(c, http.StatusInternalServerError, "讀取使用者失敗")
		return
	}

	c.JSON(http.StatusOK, gin.H{"user": user})
}

重點:

  • 輸入驗證靠 struct + binding tagbinding:"required,email" 表示這個欄位必填且要是合法 email;min=6 表示最少 6 字。c.ShouldBindJSON(&req) 會解析 JSON 並依這些規則檢查,不合就回 error。
  • handler 只做三件事:解析+驗證輸入 → 呼叫 service → 把結果或錯誤轉成 HTTP 回應。沒有任何業務邏輯(業務邏輯都在 service)。
  • 錯誤對應 HTTP 狀態:用 errors.Is 判斷 service 回的是哪種錯,對應 409(衝突)、401(未授權)、500(伺服器錯)。
  • 回傳 user 時,因為 model.UserPasswordHash 帶了 json:"-"(第 2 章),所以密碼雜湊不會出現在回應裡。

🔁 JS 對照 binding tag 的角色,等於你在 Express 用 zod / joi / express-validator 做的事:

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
});
const req = schema.parse(body); // 不合法就 throw

Go 把驗證規則直接寫在 struct 欄位旁,ShouldBindJSON 一次完成「解析 + 驗證」。


步驟 5:JWT 驗證中介層

建立 internal/middleware/auth.go

// internal/middleware/auth.go
package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"

	"no-logic-trade/internal/service"
)

// AuthRequired 回傳一個 Gin 中介層:擋掉沒帶有效 token 的請求
func AuthRequired(auth *service.AuthService) gin.HandlerFunc {
	return func(c *gin.Context) {
		// 1) 取出 Authorization 標頭
		header := c.GetHeader("Authorization")
		if header == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少 Authorization 標頭"})
			return
		}

		// 2) 格式必須是 "Bearer <token>"
		parts := strings.SplitN(header, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization 格式錯誤"})
			return
		}

		// 3) 驗證 token,取出 userID
		userID, err := auth.ParseToken(parts[1])
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token 無效或已過期"})
			return
		}

		// 4) 把 userID 放進 context,後面的 handler 就能拿到
		c.Set("userID", userID)

		// 5) 放行,進入下一個處理者
		c.Next()
	}
}

逐段解釋:

  • AuthRequired 回傳一個 gin.HandlerFunc:這是「中介層工廠」——我們把需要的 auth 服務先傳進去,它回傳真正掛在路由上的處理函式。
  • c.AbortWithStatusJSON(...):直接回應並中止這條請求(不會再進入後面的 handler)。
  • c.Set("userID", userID):把驗證出來的 userID 存進這次請求的 context,後面的 handler 用 c.GetInt64("userID") 取用。
  • c.Next():放行,交給下一個中介層或 handler。

🔁 JS 對照

function authRequired(req, res, next) {
  const header = req.headers.authorization;
  if (!header) return res.status(401).json({ error: "缺少 token" });
  const token = header.split(" ")[1];
  try {
    const { uid } = jwt.verify(token, secret);
    req.userId = uid;     // 對應 c.Set("userID", ...)
    next();               // 對應 c.Next()
  } catch {
    res.status(401).json({ error: "token 無效" });
  }
}

幾乎一對一:c.Next()next()c.Set/Get ≈ 往 req 上掛東西、c.Abort... ≈ 直接回應而不呼叫 next()


步驟 6:組裝路由(把三層串起來)

現在把所有零件接起來。更新 cmd/api/main.go

// cmd/api/main.go
package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/jmoiron/sqlx"

	"no-logic-trade/internal/config"
	"no-logic-trade/internal/database"
	"no-logic-trade/internal/handler"
	"no-logic-trade/internal/middleware"
	"no-logic-trade/internal/repository"
	"no-logic-trade/internal/service"
)

func main() {
	cfg := config.Load()

	db, err := database.Connect(cfg)
	if err != nil {
		log.Fatalf("資料庫連線失敗: %v", err)
	}
	defer db.Close()
	log.Println("✅ 資料庫連線成功")

	if err := database.Migrate(db); err != nil {
		log.Fatalf("migration 失敗: %v", err)
	}
	log.Println("✅ migration 完成")

	r := setupRouter(db, cfg)

	addr := ":" + cfg.AppPort
	log.Printf("🚀 server 啟動於 http://localhost%s", addr)
	if err := r.Run(addr); err != nil {
		log.Fatalf("server 啟動失敗: %v", err)
	}
}

// setupRouter 是整個專案的「組裝中心」:
// 由下而上建立 repository → service → handler,再掛上路由。
func setupRouter(db *sqlx.DB, cfg *config.Config) *gin.Engine {
	// --- repository 層 ---
	userRepo := repository.NewUserRepository()
	accountRepo := repository.NewAccountRepository()

	// --- service 層 ---
	authService := service.NewAuthService(db, userRepo, accountRepo, cfg.JWTSecret)

	// --- handler 層 ---
	authHandler := handler.NewAuthHandler(authService)

	// --- 路由 ---
	r := gin.Default()

	r.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	api := r.Group("/api")
	{
		// 公開路由(不需登入)
		api.POST("/auth/register", authHandler.Register)
		api.POST("/auth/login", authHandler.Login)

		// 受保護路由(需要有效 token)
		authed := api.Group("")
		authed.Use(middleware.AuthRequired(authService))
		{
			authed.GET("/me", authHandler.Me)
		}
	}

	return r
}

重點:

  • setupRouter 是組裝中心:由下而上 repository → service → handler,把相依一層層注入。之後每一章新增功能,都是在這裡多 new 幾個 repo/service/handler、多掛幾條路由。
  • r.Group("/api"):路由群組,幫所有路由加上 /api 前綴。
  • 受保護群組authed := api.Group("")authed.Use(middleware.AuthRequired(...))——掛在這個群組底下的路由,都會先過 JWT 驗證。公開的 register/login 則不在這群組裡。
  • 那對 { } 大括號只是視覺上把同群組的路由框起來,沒有語法意義(Go 允許這種裸區塊),純粹好讀。

🔁 JS 對照

const api = express.Router();
api.post("/auth/register", authHandler.register);
api.post("/auth/login", authHandler.login);

const authed = express.Router();
authed.use(authRequired);
authed.get("/me", authHandler.me);

app.use("/api", api);
app.use("/api", authed);

r.Groupexpress.Router().Use(mw)router.use(mw)


✅ 如何執行 / 驗證這一章成功

啟動(DB 容器要先在跑):

go run ./cmd/api

驗證 1:註冊

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"secret123"}'

預期回應(201 Created,注意沒有 password 欄位):

{"user":{"id":1,"email":"alice@example.com","created_at":"2026-..."}}

再註冊同一個 email 一次,預期回 409:

{"error":"email 已被註冊"}

試一個不合法的 email,預期回 400(訊息來自驗證器):

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"not-an-email","password":"123"}'
{"error":"Key: 'registerRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag\n..."}

驗證器的預設訊息較工程味,正式專案通常會把它轉成友善訊息;這裡先看到「驗證確實有作用」即可。

驗證 2:登入拿 token

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"secret123"}'

預期回應:

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

把這串 token 複製起來。為了方便,可以存進環境變數:

TOKEN="貼上你的 token"

用錯的密碼登入,預期回 401:

{"error":"帳號或密碼錯誤"}

驗證 3:帶 token 打受保護的 /me

curl http://localhost:8080/api/me \
  -H "Authorization: Bearer $TOKEN"

預期回應:

{"user":{"id":1,"email":"alice@example.com","created_at":"2026-..."}}

不帶 token,預期回 401:

curl -i http://localhost:8080/api/me
HTTP/1.1 401 Unauthorized
...
{"error":"缺少 Authorization 標頭"}

驗證 4:帳戶真的一起被建出來了

註冊時我們在交易裡同時建了 account,確認一下:

docker compose exec db psql -U nolologic -d nolologic \
  -c "SELECT id, user_id, cash_balance FROM accounts;"

預期看到 alice(user_id=1)有一個餘額 0 的帳戶:

 id | user_id | cash_balance
----+---------+--------------
  1 |       1 |            0

四項都通過,代表三層架構、密碼雜湊、JWT 簽發與驗證、中介層、交易建立 user+account 全部串起來了。🎉


這一章你完成了什麼

  • 建立了 repository → service → handler 三層,以及 setupRouter 組裝中心(之後每章照搬)。
  • DBTX interface 讓 repository 方法同時支援一般查詢與交易(呼應第 0 章 interface)。
  • 用 bcrypt 雜湊密碼、用 defer tx.Rollback() + Commit 慣用法在交易裡建立 user+account。
  • 簽發 / 驗證 JWT,寫出 AuthRequired 中介層,把 userID 透過 context 傳給後續 handler。
  • binding tag 做輸入驗證,用 errors.Is 把業務錯誤對應到正確的 HTTP 狀態碼。

下一步 → 第 4 章:帳戶與資金(入金、餘額查詢) 我們會做入金與查餘額,深入「為什麼金額要用整數的『分』」,並新增一張 transactions 資金異動表(你會看到 migration 如何隨功能長出來)。