一个真实业务(用户中心)的完整 Plumego 示例
By Birdor Engineering
目标与边界
你将得到一个可运行的用户中心(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
- HTTP 层不写业务:只做输入解析、调用 service、输出响应。
- 错误码显式稳定:
CODE用于前端/调用方逻辑分支,message用于人读。 - 鉴权中间件只做“校验+注入”:不要在 middleware 内查询数据库、拼业务对象。
- 角色以数据库为准:Refresh 时用 DB role 覆盖 token role,避免权限漂移。
- Repo 可替换:内存实现仅用于示例/测试;落库时只替换
repo.UserRepo。 - 限制请求体:即使 Plumego 有默认 body limit,也建议在 JSON 解析处再做一次“局部上限”,减少误用面。(GitHub)
下一步扩展(建议按优先级)
- Refresh Token 落库 + 失效机制(支持登出/强制下线/多端限制)
- 审计日志与安全事件(登录失败计数、IP 维度限流、密码重置)
- 租户隔离(tenant_id)(SaaS 设计可直接接入)
- RBAC 进阶:role → permission → policy(到“确实需要”再引入)