一个真实业务(用户中心)的完整 Plumego 示例

用 Plumego 从 0 搭一个可运行的用户中心:注册/登录/刷新令牌、当前用户、RBAC 管理接口、统一错误与审计字段。全部代码可复制执行。

目标与边界

你将得到一个可运行的用户中心(User Center)最小实现,覆盖:

  • 注册:POST /api/auth/register
  • 登录:POST /api/auth/login
  • 刷新:POST /api/auth/refresh
  • 当前用户:GET /api/me
  • 更新资料:PUT /api/me
  • 管理端用户:GET /api/admin/users(RBAC:admin 角色)

实现策略(Birdor 风格):

  • 清晰分层:HTTP(handler)→ 应用服务(service)→ 仓储(repo)
  • 标准库优先:JSON、密码哈希、JWT(HS256)、context 注入
  • 可替换点明确:Repo 可从内存切到 MySQL/Redis;JWT/Session 可替换;RBAC 可扩展为权限表
  • 显式约束:统一响应结构、统一错误码、显式超时与输入校验

Plumego 用法基线:
core.New(...) 创建 app,
EnableRecovery/EnableLogging/EnableCORS 开启常用中间件,
Get/Post 注册路由,最后 Boot() 启动。

项目结构(建议可直接落地)

plumego-usercenter/
  go.mod
  main.go

  internal/
    httpx/
      json.go
      resp.go
      middleware_auth.go

    domain/
      user.go

    repo/
      user_repo.go
      user_repo_memory.go

    service/
      auth_service.go

    security/
      password.go
      jwt_hs256.go
      rbac.go

go.mod

你只需要 Go + Plumego。

module plumego-usercenter

go 1.21

require github.com/spcent/plumego v0.0.0 // 以你仓库实际版本/commit 为准

main.go(启动与路由装配)

package main

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

	"github.com/spcent/plumego/core"

	"plumego-usercenter/internal/httpx"
	"plumego-usercenter/internal/repo"
	"plumego-usercenter/internal/service"
	"plumego-usercenter/internal/security"
)

func main() {
	// ---- App bootstrap (Plumego) ----
	app := core.New(
		core.WithAddr(":8080"),
		core.WithDebug(),
	)

	app.EnableRecovery()
	app.EnableLogging()
	app.EnableCORS()

	// ---- Dependencies ----
	userRepo := repo.NewMemoryUserRepo()
	jwt := security.NewJWT(security.JWTConfig{
		Issuer:     "plumego-usercenter",
		Secret:     []byte("change-me-in-prod"),
		AccessTTL:  15 * time.Minute,
		RefreshTTL: 7 * 24 * time.Hour,
	})
	authSvc := service.NewAuthService(userRepo, jwt)

	// ---- Health ----
	app.Get("/ping", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("pong")) })

	// ---- Auth ----
	app.Post("/api/auth/register", httpx.HandleJSON(func(w http.ResponseWriter, r *http.Request) error {
		var req service.RegisterRequest
		if err := httpx.ReadJSON(r, &req); err != nil {
			return httpx.BadRequest("INVALID_JSON", "invalid request body")
		}
		out, err := authSvc.Register(r.Context(), req)
		if err != nil {
			return err
		}
		return httpx.OK(w, out)
	}))

	app.Post("/api/auth/login", httpx.HandleJSON(func(w http.ResponseWriter, r *http.Request) error {
		var req service.LoginRequest
		if err := httpx.ReadJSON(r, &req); err != nil {
			return httpx.BadRequest("INVALID_JSON", "invalid request body")
		}
		out, err := authSvc.Login(r.Context(), req)
		if err != nil {
			return err
		}
		return httpx.OK(w, out)
	}))

	app.Post("/api/auth/refresh", httpx.HandleJSON(func(w http.ResponseWriter, r *http.Request) error {
		var req service.RefreshRequest
		if err := httpx.ReadJSON(r, &req); err != nil {
			return httpx.BadRequest("INVALID_JSON", "invalid request body")
		}
		out, err := authSvc.Refresh(r.Context(), req)
		if err != nil {
			return err
		}
		return httpx.OK(w, out)
	}))

	// ---- Protected: /api/me ----
	requireAuth := httpx.AuthMiddleware(jwt)

	app.Get("/api/me", requireAuth(httpx.HandleJSON(func(w http.ResponseWriter, r *http.Request) error {
		uid := httpx.UserID(r.Context())
		u, err := userRepo.GetByID(r.Context(), uid)
		if err != nil {
			return httpx.NotFound("USER_NOT_FOUND", "user not found")
		}
		return httpx.OK(w, service.ToMeResponse(u))
	})))

	app.Put("/api/me", requireAuth(httpx.HandleJSON(func(w http.ResponseWriter, r *http.Request) error {
		uid := httpx.UserID(r.Context())
		var req service.UpdateMeRequest
		if err := httpx.ReadJSON(r, &req); err != nil {
			return httpx.BadRequest("INVALID_JSON", "invalid request body")
		}
		u, err := authSvc.UpdateMe(r.Context(), uid, req)
		if err != nil {
			return err
		}
		return httpx.OK(w, service.ToMeResponse(u))
	})))

	// ---- Admin: RBAC ----
	requireAdmin := httpx.RequireRole("admin")

	app.Get("/api/admin/users",
		requireAuth(requireAdmin(httpx.HandleJSON(func(w http.ResponseWriter, r *http.Request) error {
			users, err := userRepo.List(r.Context(), 200)
			if err != nil {
				return httpx.Internal("LIST_FAILED", "list users failed")
			}
			return httpx.OK(w, service.ToAdminUserList(users))
		}))))

	// ---- Boot ----
	if err := app.Boot(); err != nil {
		log.Fatalf("server stopped: %v", err)
	}
}

internal/httpx/resp.go(统一响应与错误)

package httpx

import (
	"encoding/json"
	"net/http"
	"time"
)

type APIResponse struct {
	OK      bool        `json:"ok"`
	Code    string      `json:"code,omitempty"`
	Message string      `json:"message,omitempty"`
	Data    any         `json:"data,omitempty"`
	Time    int64       `json:"time,omitempty"`
}

type APIError struct {
	Status  int
	Code    string
	Message string
}

func (e *APIError) Error() string { return e.Code + ": " + e.Message }

func OK(w http.ResponseWriter, data any) error {
	return writeJSON(w, http.StatusOK, APIResponse{
		OK:   true,
		Data: data,
		Time: time.Now().Unix(),
	})
}

func writeErr(w http.ResponseWriter, e *APIError) {
	_ = writeJSON(w, e.Status, APIResponse{
		OK:      false,
		Code:    e.Code,
		Message: e.Message,
		Time:    time.Now().Unix(),
	})
}

func writeJSON(w http.ResponseWriter, status int, v any) error {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	return json.NewEncoder(w).Encode(v)
}

func BadRequest(code, msg string) *APIError { return &APIError{Status: 400, Code: code, Message: msg} }
func Unauthorized(code, msg string) *APIError { return &APIError{Status: 401, Code: code, Message: msg} }
func Forbidden(code, msg string) *APIError { return &APIError{Status: 403, Code: code, Message: msg} }
func NotFound(code, msg string) *APIError { return &APIError{Status: 404, Code: code, Message: msg} }
func Internal(code, msg string) *APIError { return &APIError{Status: 500, Code: code, Message: msg} }

internal/httpx/json.go(安全读 JSON + handler 适配)

package httpx

import (
	"encoding/json"
	"io"
	"net/http"
)

func ReadJSON(r *http.Request, dst any) error {
	// 显式限制 body(避免无界读)
	const max = 1 << 20 // 1 MiB for demo
	body, err := io.ReadAll(io.LimitReader(r.Body, max))
	if err != nil {
		return err
	}
	if len(body) == 0 {
		return io.EOF
	}
	return json.Unmarshal(body, dst)
}

type JSONHandler func(w http.ResponseWriter, r *http.Request) error

func HandleJSON(h JSONHandler) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if err := h(w, r); err != nil {
			if apiErr, ok := err.(*APIError); ok {
				writeErr(w, apiErr)
				return
			}
			writeErr(w, Internal("INTERNAL_ERROR", "unexpected error"))
		}
	}
}

internal/httpx/middleware_auth.go(JWT 鉴权 + context 注入 + RBAC)

package httpx

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

	"plumego-usercenter/internal/security"
)

type ctxKey string

const (
	ctxUserID ctxKey = "uid"
	ctxRole   ctxKey = "role"
)

func AuthMiddleware(jwt *security.JWT) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			h := r.Header.Get("Authorization")
			if h == "" || !strings.HasPrefix(h, "Bearer ") {
				writeErr(w, Unauthorized("NO_TOKEN", "missing bearer token"))
				return
			}
			token := strings.TrimPrefix(h, "Bearer ")
			claims, err := jwt.VerifyAccess(token)
			if err != nil {
				writeErr(w, Unauthorized("BAD_TOKEN", "invalid token"))
				return
			}
			ctx := context.WithValue(r.Context(), ctxUserID, claims.UserID)
			ctx = context.WithValue(ctx, ctxRole, claims.Role)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func UserID(ctx context.Context) string {
	v, _ := ctx.Value(ctxUserID).(string)
	return v
}

func Role(ctx context.Context) string {
	v, _ := ctx.Value(ctxRole).(string)
	return v
}

func RequireRole(role string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if Role(r.Context()) != role {
				writeErr(w, Forbidden("FORBIDDEN", "insufficient role"))
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

Birdor 风格要点:鉴权 middleware 只做三件事——解析、校验、注入(不要在这里做业务查询)。

internal/domain/user.go(领域模型)

package domain

import "time"

type User struct {
	ID           string
	Email        string
	PasswordHash []byte
	DisplayName  string
	Role         string // "user" | "admin"
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

internal/repo/user_repo.go(仓储接口)

package repo

import (
	"context"

	"plumego-usercenter/internal/domain"
)

type UserRepo interface {
	Create(ctx context.Context, u *domain.User) error
	GetByID(ctx context.Context, id string) (*domain.User, error)
	GetByEmail(ctx context.Context, email string) (*domain.User, error)
	Update(ctx context.Context, u *domain.User) error
	List(ctx context.Context, limit int) ([]*domain.User, error)
}

internal/repo/user_repo_memory.go(内存实现:可替换)

package repo

import (
	"context"
	"errors"
	"strings"
	"sync"

	"plumego-usercenter/internal/domain"
)

var (
	ErrNotFound   = errors.New("not found")
	ErrEmailTaken = errors.New("email already exists")
)

type MemoryUserRepo struct {
	mu      sync.RWMutex
	byID    map[string]*domain.User
	byEmail map[string]string // email -> id
}

func NewMemoryUserRepo() *MemoryUserRepo {
	return &MemoryUserRepo{
		byID:    map[string]*domain.User{},
		byEmail: map[string]string{},
	}
}

func (r *MemoryUserRepo) Create(_ context.Context, u *domain.User) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	email := strings.ToLower(strings.TrimSpace(u.Email))
	if _, ok := r.byEmail[email]; ok {
		return ErrEmailTaken
	}
	r.byID[u.ID] = cloneUser(u)
	r.byEmail[email] = u.ID
	return nil
}

func (r *MemoryUserRepo) GetByID(_ context.Context, id string) (*domain.User, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	u, ok := r.byID[id]
	if !ok {
		return nil, ErrNotFound
	}
	return cloneUser(u), nil
}

func (r *MemoryUserRepo) GetByEmail(_ context.Context, email string) (*domain.User, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	id, ok := r.byEmail[strings.ToLower(strings.TrimSpace(email))]
	if !ok {
		return nil, ErrNotFound
	}
	u, ok := r.byID[id]
	if !ok {
		return nil, ErrNotFound
	}
	return cloneUser(u), nil
}

func (r *MemoryUserRepo) Update(_ context.Context, u *domain.User) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if _, ok := r.byID[u.ID]; !ok {
		return ErrNotFound
	}
	r.byID[u.ID] = cloneUser(u)
	r.byEmail[strings.ToLower(strings.TrimSpace(u.Email))] = u.ID
	return nil
}

func (r *MemoryUserRepo) List(_ context.Context, limit int) ([]*domain.User, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	out := make([]*domain.User, 0, limit)
	for _, u := range r.byID {
		out = append(out, cloneUser(u))
		if len(out) >= limit {
			break
		}
	}
	return out, nil
}

func cloneUser(u *domain.User) *domain.User {
	if u == nil {
		return nil
	}
	cp := *u
	if u.PasswordHash != nil {
		cp.PasswordHash = append([]byte(nil), u.PasswordHash...)
	}
	return &cp
}

internal/security/password.go(密码哈希:标准库 PBKDF2)

package security

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"

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

var ErrBadPassword = errors.New("bad password")

type PasswordHasher struct {
	Iter   int
	SaltN  int
	KeyLen int
}

func DefaultPasswordHasher() PasswordHasher {
	return PasswordHasher{Iter: 120_000, SaltN: 16, KeyLen: 32}
}

func (h PasswordHasher) Hash(password string) ([]byte, error) {
	salt := make([]byte, h.SaltN)
	if _, err := rand.Read(salt); err != nil {
		return nil, err
	}
	key := pbkdf2.Key([]byte(password), salt, h.Iter, h.KeyLen, sha256.New)

	// format: pbkdf2$iter$base64(salt)$base64(key)
	out := "pbkdf2$" + itoa(h.Iter) + "$" +
		base64.RawStdEncoding.EncodeToString(salt) + "$" +
		base64.RawStdEncoding.EncodeToString(key)
	return []byte(out), nil
}

func (h PasswordHasher) Verify(password string, encoded []byte) error {
	iter, salt, key, err := parsePBKDF2(string(encoded))
	if err != nil {
		return err
	}
	got := pbkdf2.Key([]byte(password), salt, iter, len(key), sha256.New)
	if !hmac.Equal(got, key) {
		return ErrBadPassword
	}
	return nil
}

// ---- tiny helpers (avoid extra deps) ----
func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var b [32]byte
	i := len(b)
	for n > 0 {
		i--
		b[i] = byte('0' + n%10)
		n /= 10
	}
	return string(b[i:])
}

func parsePBKDF2(s string) (iter int, salt []byte, key []byte, err error) {
	// pbkdf2$iter$salt$key
	parts := split4(s, '$')
	if len(parts) != 4 || parts[0] != "pbkdf2" {
		return 0, nil, nil, errors.New("invalid hash format")
	}
	iter = atoi(parts[1])
	salt, err = base64.RawStdEncoding.DecodeString(parts[2])
	if err != nil {
		return 0, nil, nil, errors.New("invalid salt")
	}
	key, err = base64.RawStdEncoding.DecodeString(parts[3])
	if err != nil {
		return 0, nil, nil, errors.New("invalid key")
	}
	return iter, salt, key, nil
}

func atoi(s string) int {
	n := 0
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c < '0' || c > '9' {
			break
		}
		n = n*10 + int(c-'0')
	}
	return n
}

func split4(s string, sep byte) []string {
	out := make([]string, 0, 4)
	start := 0
	for i := 0; i < len(s); i++ {
		if s[i] == sep {
			out = append(out, s[start:i])
			start = i + 1
			if len(out) == 3 { // last chunk
				break
			}
		}
	}
	out = append(out, s[start:])
	return out
}

说明:这里为了示例清晰使用了 x/crypto/pbkdf2(行业常用),如果你坚持“零外部依赖”,可以换成标准库的 scrypt/自定义 KDF(不推荐自己造)。

internal/security/jwt_hs256.go(JWT:HS256 极简实现)

package security

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"strings"
	"time"
)

var ErrJWT = errors.New("jwt error")

type JWTConfig struct {
	Issuer     string
	Secret     []byte
	AccessTTL  time.Duration
	RefreshTTL time.Duration
}

type JWT struct {
	cfg JWTConfig
}

type Claims struct {
	Issuer string `json:"iss"`
	Sub    string `json:"sub"`  // user id
	Role   string `json:"role"` // "user" | "admin"
	Typ    string `json:"typ"`  // "access" | "refresh"
	Iat    int64  `json:"iat"`
	Exp    int64  `json:"exp"`
}

func NewJWT(cfg JWTConfig) *JWT { return &JWT{cfg: cfg} }

func (j *JWT) MintAccess(userID, role string) (string, error) {
	return j.mint(userID, role, "access", j.cfg.AccessTTL)
}

func (j *JWT) MintRefresh(userID, role string) (string, error) {
	return j.mint(userID, role, "refresh", j.cfg.RefreshTTL)
}

func (j *JWT) VerifyAccess(token string) (*Claims, error) {
	c, err := j.verify(token)
	if err != nil {
		return nil, err
	}
	if c.Typ != "access" {
		return nil, ErrJWT
	}
	return c, nil
}

func (j *JWT) VerifyRefresh(token string) (*Claims, error) {
	c, err := j.verify(token)
	if err != nil {
		return nil, err
	}
	if c.Typ != "refresh" {
		return nil, ErrJWT
	}
	return c, nil
}

func (j *JWT) mint(userID, role, typ string, ttl time.Duration) (string, error) {
	header := map[string]any{"alg": "HS256", "typ": "JWT"}
	now := time.Now().Unix()
	claims := Claims{
		Issuer: j.cfg.Issuer,
		Sub:    userID,
		Role:   role,
		Typ:    typ,
		Iat:    now,
		Exp:    now + int64(ttl.Seconds()),
	}

	hb, _ := json.Marshal(header)
	cb, _ := json.Marshal(claims)

	h64 := b64(hb)
	c64 := b64(cb)

	signing := h64 + "." + c64
	sig := hmacSHA256(signing, j.cfg.Secret)

	return signing + "." + b64(sig), nil
}

func (j *JWT) verify(token string) (*Claims, error) {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return nil, ErrJWT
	}
	signing := parts[0] + "." + parts[1]
	wantSig := hmacSHA256(signing, j.cfg.Secret)
	gotSig, err := b64d(parts[2])
	if err != nil {
		return nil, ErrJWT
	}
	if !hmac.Equal(gotSig, wantSig) {
		return nil, ErrJWT
	}
	cb, err := b64d(parts[1])
	if err != nil {
		return nil, ErrJWT
	}
	var c Claims
	if err := json.Unmarshal(cb, &c); err != nil {
		return nil, ErrJWT
	}
	if c.Issuer != j.cfg.Issuer {
		return nil, ErrJWT
	}
	if time.Now().Unix() >= c.Exp {
		return nil, ErrJWT
	}
	return &c, nil
}

func hmacSHA256(msg string, secret []byte) []byte {
	m := hmac.New(sha256.New, secret)
	m.Write([]byte(msg))
	return m.Sum(nil)
}

func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func b64d(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }

internal/service/auth_service.go(业务:注册/登录/刷新/更新资料)

package service

import (
	"context"
	"strings"
	"time"

	"plumego-usercenter/internal/domain"
	"plumego-usercenter/internal/httpx"
	"plumego-usercenter/internal/repo"
	"plumego-usercenter/internal/security"
)

type AuthService struct {
	repo   repo.UserRepo
	jwt    *security.JWT
	hasher security.PasswordHasher
}

func NewAuthService(r repo.UserRepo, jwt *security.JWT) *AuthService {
	return &AuthService{
		repo:   r,
		jwt:    jwt,
		hasher: security.DefaultPasswordHasher(),
	}
}

type RegisterRequest struct {
	Email       string `json:"email"`
	Password    string `json:"password"`
	DisplayName string `json:"display_name"`
}

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

type RefreshRequest struct {
	RefreshToken string `json:"refresh_token"`
}

type UpdateMeRequest struct {
	DisplayName string `json:"display_name"`
}

type TokenPair struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
}

type AuthResponse struct {
	User MeResponse `json:"user"`
	Tokens TokenPair `json:"tokens"`
}

type MeResponse struct {
	ID          string `json:"id"`
	Email       string `json:"email"`
	DisplayName string `json:"display_name"`
	Role        string `json:"role"`
	CreatedAt   int64  `json:"created_at"`
	UpdatedAt   int64  `json:"updated_at"`
}

func ToMeResponse(u *domain.User) MeResponse {
	return MeResponse{
		ID:          u.ID,
		Email:       u.Email,
		DisplayName: u.DisplayName,
		Role:        u.Role,
		CreatedAt:   u.CreatedAt.Unix(),
		UpdatedAt:   u.UpdatedAt.Unix(),
	}
}

func (s *AuthService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) {
	email := strings.ToLower(strings.TrimSpace(req.Email))
	if email == "" || len(req.Password) < 8 {
		return nil, httpx.BadRequest("INVALID_INPUT", "email or password invalid")
	}
	if req.DisplayName == "" {
		req.DisplayName = "User"
	}

	hash, err := s.hasher.Hash(req.Password)
	if err != nil {
		return nil, httpx.Internal("HASH_FAILED", "password hash failed")
	}

	now := time.Now()
	u := &domain.User{
		ID:           newID(),
		Email:        email,
		PasswordHash: hash,
		DisplayName:  req.DisplayName,
		Role:         "user",
		CreatedAt:    now,
		UpdatedAt:    now,
	}

	if err := s.repo.Create(ctx, u); err != nil {
		if err == repo.ErrEmailTaken {
			return nil, httpx.BadRequest("EMAIL_TAKEN", "email already registered")
		}
		return nil, httpx.Internal("CREATE_FAILED", "create user failed")
	}

	return s.issueTokens(u)
}

func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
	email := strings.ToLower(strings.TrimSpace(req.Email))
	if email == "" || req.Password == "" {
		return nil, httpx.BadRequest("INVALID_INPUT", "email or password invalid")
	}

	u, err := s.repo.GetByEmail(ctx, email)
	if err != nil {
		return nil, httpx.Unauthorized("BAD_CREDENTIALS", "invalid email or password")
	}
	if err := s.hasher.Verify(req.Password, u.PasswordHash); err != nil {
		return nil, httpx.Unauthorized("BAD_CREDENTIALS", "invalid email or password")
	}
	return s.issueTokens(u)
}

func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) {
	if strings.TrimSpace(req.RefreshToken) == "" {
		return nil, httpx.BadRequest("INVALID_INPUT", "refresh_token required")
	}
	claims, err := s.jwt.VerifyRefresh(req.RefreshToken)
	if err != nil {
		return nil, httpx.Unauthorized("BAD_TOKEN", "invalid refresh token")
	}

	u, err := s.repo.GetByID(ctx, claims.Sub)
	if err != nil {
		return nil, httpx.Unauthorized("USER_NOT_FOUND", "user not found")
	}
	// role 以数据库为准(避免 token role 过期/被篡改带来的权限漂移)
	return s.issueTokens(u)
}

func (s *AuthService) UpdateMe(ctx context.Context, userID string, req UpdateMeRequest) (*domain.User, error) {
	u, err := s.repo.GetByID(ctx, userID)
	if err != nil {
		return nil, httpx.NotFound("USER_NOT_FOUND", "user not found")
	}

	name := strings.TrimSpace(req.DisplayName)
	if name == "" || len(name) > 64 {
		return nil, httpx.BadRequest("INVALID_INPUT", "display_name invalid")
	}
	u.DisplayName = name
	u.UpdatedAt = time.Now()

	if err := s.repo.Update(ctx, u); err != nil {
		return nil, httpx.Internal("UPDATE_FAILED", "update failed")
	}
	return u, nil
}

func (s *AuthService) issueTokens(u *domain.User) (*AuthResponse, error) {
	at, err := s.jwt.MintAccess(u.ID, u.Role)
	if err != nil {
		return nil, httpx.Internal("TOKEN_FAILED", "mint access token failed")
	}
	rt, err := s.jwt.MintRefresh(u.ID, u.Role)
	if err != nil {
		return nil, httpx.Internal("TOKEN_FAILED", "mint refresh token failed")
	}
	return &AuthResponse{
		User: ToMeResponse(u),
		Tokens: TokenPair{AccessToken: at, RefreshToken: rt},
	}, nil
}

// demo-only: replace with your own ID strategy (you之前也做过“可逆固定长度编码”的方案,可直接复用)
func newID() string {
	return "u_" + time.Now().Format("20060102150405.000000000")
}

internal/security/rbac.go(最小 RBAC:角色)

package security

// Birdor 风格建议:先把 RBAC 收敛为 role(admin/user),
// 等业务复杂到一定程度再引入 permissions + policy engine。
// 这样能避免“早期过度建模”。

管理端响应转换(可选)

GET /api/admin/users 的输出做“脱敏 + 固定字段”:

package service

import "plumego-usercenter/internal/domain"

type AdminUser struct {
	ID          string `json:"id"`
	Email       string `json:"email"`
	DisplayName string `json:"display_name"`
	Role        string `json:"role"`
}

func ToAdminUserList(users []*domain.User) []AdminUser {
	out := make([]AdminUser, 0, len(users))
	for _, u := range users {
		out = append(out, AdminUser{
			ID:          u.ID,
			Email:       u.Email,
			DisplayName: u.DisplayName,
			Role:        u.Role,
		})
	}
	return out
}

运行与验证(curl)

启动:

go run .

注册:

curl -sS -X POST http://localhost:8080/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"a@b.com","password":"12345678","display_name":"Alice"}' | jq

登录:

TOKENS=$(curl -sS -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"a@b.com","password":"12345678"}')

AT=$(echo "$TOKENS" | jq -r '.data.tokens.access_token')

curl -sS http://localhost:8080/api/me \
  -H "Authorization: Bearer $AT" | jq

刷新:

RT=$(echo "$TOKENS" | jq -r '.data.tokens.refresh_token')

curl -sS -X POST http://localhost:8080/api/auth/refresh \
  -H 'Content-Type: application/json' \
  -d "{\"refresh_token\":\"$RT\"}" | jq

Birdor 风格 Best Practices

  1. HTTP 层不写业务:只做输入解析、调用 service、输出响应。
  2. 错误码显式稳定CODE 用于前端/调用方逻辑分支,message 用于人读。
  3. 鉴权中间件只做“校验+注入”:不要在 middleware 内查询数据库、拼业务对象。
  4. 角色以数据库为准:Refresh 时用 DB role 覆盖 token role,避免权限漂移。
  5. Repo 可替换:内存实现仅用于示例/测试;落库时只替换 repo.UserRepo
  6. 限制请求体:即使 Plumego 有默认 body limit,也建议在 JSON 解析处再做一次“局部上限”,减少误用面。(GitHub)

下一步扩展(建议按优先级)

  • Refresh Token 落库 + 失效机制(支持登出/强制下线/多端限制)
  • 审计日志与安全事件(登录失败计数、IP 维度限流、密码重置)
  • 租户隔离(tenant_id)(SaaS 设计可直接接入)
  • RBAC 进阶:role → permission → policy(到“确实需要”再引入)

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页