第 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/bcryptjs;golang-jwt≈jsonwebtoken。用法你會很熟悉:雜湊、比對、簽 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 的dbtag 自動塞進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 後,會同時有uid和exp、iat。
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)
}
兩個關鍵:
-
email 重複檢查:先用
GetByEmail查。err == nil(查得到)→ 回ErrEmailTaken。err是其他錯誤(不是sql.ErrNoRows)→ 真的出錯,往上拋。err是sql.ErrNoRows(查無)→ 代表沒被用過,繼續註冊。errors.Is(err, sql.ErrNoRows)是判斷「是不是某個特定錯誤」的標準寫法。
-
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 +
bindingtag:binding:"required,email"表示這個欄位必填且要是合法 email;min=6表示最少 6 字。c.ShouldBindJSON(&req)會解析 JSON 並依這些規則檢查,不合就回 error。 - handler 只做三件事:解析+驗證輸入 → 呼叫 service → 把結果或錯誤轉成 HTTP 回應。沒有任何業務邏輯(業務邏輯都在 service)。
- 錯誤對應 HTTP 狀態:用
errors.Is判斷 service 回的是哪種錯,對應 409(衝突)、401(未授權)、500(伺服器錯)。 - 回傳
user時,因為model.User的PasswordHash帶了json:"-"(第 2 章),所以密碼雜湊不會出現在回應裡。
🔁 JS 對照
bindingtag 的角色,等於你在 Express 用zod/joi/express-validator做的事:const schema = z.object({ email: z.string().email(), password: z.string().min(6), }); const req = schema.parse(body); // 不合法就 throwGo 把驗證規則直接寫在 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.Group≈express.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組裝中心(之後每章照搬)。 - 用
DBTXinterface 讓 repository 方法同時支援一般查詢與交易(呼應第 0 章 interface)。 - 用 bcrypt 雜湊密碼、用
defer tx.Rollback()+Commit慣用法在交易裡建立 user+account。 - 簽發 / 驗證 JWT,寫出
AuthRequired中介層,把userID透過 context 傳給後續 handler。 - 用
bindingtag 做輸入驗證,用errors.Is把業務錯誤對應到正確的 HTTP 狀態碼。
下一步 → 第 4 章:帳戶與資金(入金、餘額查詢)
我們會做入金與查餘額,深入「為什麼金額要用整數的『分』」,並新增一張 transactions 資金異動表(你會看到 migration 如何隨功能長出來)。