Plumego Best Practices · 一个真实业务(用户中心)的完整示例
By Birdor Engineering
0. 这份示例解决什么问题
你已经有了 Plumego,但真正把它用进“用户中心”这种业务时,通常会卡在三件事:
- 模块边界:User / Auth / Project 怎么拆,拆完怎么组装,才不会越写越乱。
- 请求与错误模型:统一 JSON 输出、统一错误码、统一 trace/request id,把“可观察性”内建进骨架。
- 鉴权:最小可用、可扩展、可替换(未来可接 JWT / OAuth / SSO),但现在就能跑通。
Plumego 的定位是“标准库内的 Go HTTP 工具箱”,用 core.App 组合能力,而不是把你锁死在某种框架范式里。
你会看到本文的写法更接近 Birdor 的工程风格:少魔法、强边界、可演进。
1. API 设计(最小但完整)
统一前缀:
/api/v1
Auth
POST /api/v1/auth/register注册POST /api/v1/auth/login登录(返回 access_token + refresh_token)POST /api/v1/auth/refresh刷新 access_tokenPOST /api/v1/auth/logout退出(作废 refresh_token)
User
GET /api/v1/users/me获取当前用户PUT /api/v1/users/me更新当前用户
Project
POST /api/v1/projects创建项目GET /api/v1/projects列表(仅自己的)GET /api/v1/projects/:id详情PUT /api/v1/projects/:id更新DELETE /api/v1/projects/:id删除
2. Best Practices(Birdor 风格的“可执行原则”)
2.1 不把业务写进 handler
- handler 只做三件事:解析输入、调用 usecase/service、写出响应
- usecase/service 只做业务,不碰
http.*
2.2 统一错误模型(业务错误 ≠ 500)
- 返回结构固定:
ok: true/falsecode: "..."(稳定、可检索)message: "..."(给人看的)request_id: "..."(排障必须)data: ...(成功时)
2.3 认证要“可替换”
- 本文用一个轻量可逆的 token 格式(HMAC 签名 + 过期时间 + user_id)
- 未来要换 JWT:只需要替换
TokenService,其余模块不动
2.4 Plumego 的使用原则
core.New(...)只负责“应用装配”EnableRecovery / EnableLogging / EnableCORS等通用能力尽量在启动时一次性打开- 路由注册保持“模块化集中”,不要把所有路由都堆
main.go
3. 目录结构
user-center/
go.mod
cmd/server/main.go
internal/httpx/
json.go
errors.go
request_id.go
internal/auth/
model.go
tokens.go
middleware.go
handler.go
service.go
repo.go
internal/user/
model.go
handler.go
service.go
repo.go
internal/project/
model.go
handler.go
service.go
repo.go
internal/app/
wiring.go
4. 代码:从 main.go 开始(Plumego 装配)
Plumego README 的 Quick start 展示了
core.New(...)+EnableRecovery/EnableLogging/EnableCORS+app.Get(...)+app.Boot()这条主线。([GitHub][1])
本示例沿用相同风格,只把路由与模块装配下沉到internal/app/wiring.go。
cmd/server/main.go
package main
import (
"log"
"github.com/spcent/plumego/core"
"user-center/internal/app"
)
func main() {
a := core.New(
core.WithAddr(":8080"),
core.WithDebug(),
)
// 基础中间件(Plumego 提供的快捷开关)
a.EnableRecovery()
a.EnableLogging()
a.EnableCORS()
// 业务装配(Birdor 风格:main 只做 wiring)
app.Wire(a)
if err := a.Boot(); err != nil {
log.Fatalf("server stopped: %v", err)
}
}
5. HTTP 基建:统一 JSON、错误与 Request-ID
internal/httpx/request_id.go
package httpx
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
)
const HeaderRequestID = "X-Request-Id"
func EnsureRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get(HeaderRequestID)
if rid == "" {
rid = newRID()
}
w.Header().Set(HeaderRequestID, rid)
ctx := WithRequestID(r.Context(), rid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func newRID() string {
// 16 bytes random + unix ms(便于排序/排障)
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b) + "-" + itoa64(time.Now().UnixMilli())
}
// 避免 fmt.Sprintf 的分配:足够用的 64-bit 正整数转字符串
func itoa64(v int64) string {
if v == 0 {
return "0"
}
var buf [32]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
internal/httpx/json.go
package httpx
import (
"encoding/json"
"net/http"
)
type Envelope struct {
OK bool `json:"ok"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
RequestID string `json:"request_id,omitempty"`
Data any `json:"data,omitempty"`
}
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func OK(w http.ResponseWriter, r *http.Request, data any) {
WriteJSON(w, http.StatusOK, Envelope{
OK: true,
RequestID: RequestID(r.Context()),
Data: data,
})
}
func Fail(w http.ResponseWriter, r *http.Request, status int, code, msg string) {
WriteJSON(w, status, Envelope{
OK: false,
Code: code,
Message: msg,
RequestID: RequestID(r.Context()),
})
}
internal/httpx/errors.go
package httpx
import "net/http"
type AppError struct {
Code string
Message string
Status int
}
func (e *AppError) Error() string { return e.Code + ": " + e.Message }
func BadRequest(code, msg string) *AppError {
return &AppError{Code: code, Message: msg, Status: http.StatusBadRequest}
}
func Unauthorized(code, msg string) *AppError {
return &AppError{Code: code, Message: msg, Status: http.StatusUnauthorized}
}
func Forbidden(code, msg string) *AppError {
return &AppError{Code: code, Message: msg, Status: http.StatusForbidden}
}
func NotFound(code, msg string) *AppError {
return &AppError{Code: code, Message: msg, Status: http.StatusNotFound}
}
func Conflict(code, msg string) *AppError {
return &AppError{Code: code, Message: msg, Status: http.StatusConflict}
}
func Internal(code, msg string) *AppError {
return &AppError{Code: code, Message: msg, Status: http.StatusInternalServerError}
}
说明:这里没有依赖任何“框架错误处理魔法”。错误如何映射到响应,由 handler 明确控制;未来你要换成统一的 error middleware,也能无痛迁移。
6. Auth:Token + Refresh(可替换的实现)
6.1 Token 模型与签发/校验
internal/auth/tokens.go
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"errors"
"strings"
"time"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrExpiredToken = errors.New("expired token")
)
type TokenService struct {
secret []byte
ttl time.Duration
}
func NewTokenService(secret string, ttl time.Duration) *TokenService {
return &TokenService{secret: []byte(secret), ttl: ttl}
}
// token 格式(URL-safe,便于 header / query 传输)
// v1.<payloadB64>.<sigB64>
// payload: user_id(uint64) + exp_unix(int64)
func (s *TokenService) Issue(userID uint64, now time.Time) (string, time.Time) {
exp := now.Add(s.ttl)
payload := make([]byte, 16)
binary.BigEndian.PutUint64(payload[0:8], userID)
binary.BigEndian.PutUint64(payload[8:16], uint64(exp.Unix()))
pb := b64(payload)
sb := b64(sign(s.secret, payload))
return "v1." + pb + "." + sb, exp
}
func (s *TokenService) Verify(token string, now time.Time) (uint64, time.Time, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 || parts[0] != "v1" {
return 0, time.Time{}, ErrInvalidToken
}
payload, err := unb64(parts[1])
if err != nil || len(payload) != 16 {
return 0, time.Time{}, ErrInvalidToken
}
sig, err := unb64(parts[2])
if err != nil {
return 0, time.Time{}, ErrInvalidToken
}
expect := sign(s.secret, payload)
if !hmac.Equal(sig, expect) {
return 0, time.Time{}, ErrInvalidToken
}
uid := binary.BigEndian.Uint64(payload[0:8])
expUnix := int64(binary.BigEndian.Uint64(payload[8:16]))
exp := time.Unix(expUnix, 0)
if now.After(exp) {
return 0, exp, ErrExpiredToken
}
return uid, exp, nil
}
func sign(secret, payload []byte) []byte {
h := hmac.New(sha256.New, secret)
h.Write(payload)
return h.Sum(nil)
}
func b64(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
func unb64(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
6.2 Auth Middleware(把 user_id 注入 context)
internal/auth/middleware.go
package auth
import (
"net/http"
"strings"
"time"
"user-center/internal/httpx"
)
type ctxKey int
const ctxUserID ctxKey = 1
func WithUserID(r *http.Request, userID uint64) *http.Request {
ctx := r.Context()
ctx = contextWithUserID(ctx, userID)
return r.WithContext(ctx)
}
func UserIDFrom(r *http.Request) (uint64, bool) {
return userIDFromContext(r.Context())
}
// RequireAuth: Bearer <token>
func RequireAuth(ts *TokenService, now func() time.Time) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authz := r.Header.Get("Authorization")
if authz == "" {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_MISSING", "missing authorization header")
return
}
token := strings.TrimSpace(strings.TrimPrefix(authz, "Bearer"))
token = strings.TrimSpace(token)
if token == "" {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_INVALID", "invalid authorization header")
return
}
uid, _, err := ts.Verify(token, now())
if err != nil {
code := "AUTH_INVALID"
msg := "invalid token"
if err == ErrExpiredToken {
code = "AUTH_EXPIRED"
msg = "token expired"
}
httpx.Fail(w, r, http.StatusUnauthorized, code, msg)
return
}
next.ServeHTTP(w, WithUserID(r, uid))
})
}
}
internal/auth/model.go
package auth
import "context"
func contextWithUserID(ctx context.Context, userID uint64) context.Context {
return context.WithValue(ctx, ctxUserID, userID)
}
func userIDFromContext(ctx context.Context) (uint64, bool) {
v := ctx.Value(ctxUserID)
uid, ok := v.(uint64)
return uid, ok
}
这里的 auth middleware 完全是标准库
http.Handler组合方式,符合 Plumego 的“工具箱”定位:你可以用它包住任意路由处理函数。([GitHub][1])
6.3 Refresh Token Repo(内存实现,后续可替换 DB/Redis)
internal/auth/repo.go
package auth
import (
"sync"
"time"
)
type RefreshRecord struct {
UserID uint64
ExpiresAt time.Time
Revoked bool
}
type RefreshRepo interface {
Upsert(token string, rec RefreshRecord)
Get(token string) (RefreshRecord, bool)
Revoke(token string)
}
type MemRefreshRepo struct {
mu sync.RWMutex
m map[string]RefreshRecord
}
func NewMemRefreshRepo() *MemRefreshRepo {
return &MemRefreshRepo{m: make(map[string]RefreshRecord)}
}
func (r *MemRefreshRepo) Upsert(token string, rec RefreshRecord) {
r.mu.Lock()
defer r.mu.Unlock()
r.m[token] = rec
}
func (r *MemRefreshRepo) Get(token string) (RefreshRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
rec, ok := r.m[token]
return rec, ok
}
func (r *MemRefreshRepo) Revoke(token string) {
r.mu.Lock()
defer r.mu.Unlock()
rec, ok := r.m[token]
if !ok {
return
}
rec.Revoked = true
r.m[token] = rec
}
6.4 Auth Service + Handler
internal/auth/service.go
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"user-center/internal/user"
)
var ErrBadCreds = errors.New("bad credentials")
type Service struct {
users user.Repo
access *TokenService
refresh RefreshRepo
refreshTTL time.Duration
now func() time.Time
}
func NewService(users user.Repo, access *TokenService, refresh RefreshRepo, refreshTTL time.Duration, now func() time.Time) *Service {
return &Service{
users: users,
access: access,
refresh: refresh,
refreshTTL: refreshTTL,
now: now,
}
}
func (s *Service) Register(email, password, name string) (user.User, error) {
return s.users.Create(email, password, name)
}
func (s *Service) Login(email, password string) (accessToken string, accessExp time.Time, refreshToken string, refreshExp time.Time, err error) {
u, ok := s.users.FindByEmail(email)
if !ok || !user.VerifyPassword(u.PasswordHash, password) {
return "", time.Time{}, "", time.Time{}, ErrBadCreds
}
at, aexp := s.access.Issue(u.ID, s.now())
rt, rexp := issueRefresh(s.now(), s.refreshTTL)
s.refresh.Upsert(rt, RefreshRecord{UserID: u.ID, ExpiresAt: rexp})
return at, aexp, rt, rexp, nil
}
func (s *Service) Refresh(refreshToken string) (string, time.Time, error) {
rec, ok := s.refresh.Get(refreshToken)
if !ok || rec.Revoked {
return "", time.Time{}, ErrInvalidToken
}
if s.now().After(rec.ExpiresAt) {
return "", time.Time{}, ErrExpiredToken
}
at, exp := s.access.Issue(rec.UserID, s.now())
return at, exp, nil
}
func (s *Service) Logout(refreshToken string) {
s.refresh.Revoke(refreshToken)
}
func issueRefresh(now time.Time, ttl time.Duration) (string, time.Time) {
b := make([]byte, 32)
_, _ = rand.Read(b)
tok := base64.RawURLEncoding.EncodeToString(b)
return "r1." + tok, now.Add(ttl)
}
internal/auth/handler.go
package auth
import (
"encoding/json"
"net/http"
"time"
"user-center/internal/httpx"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
type registerReq struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var req registerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_JSON", "invalid json")
return
}
u, err := h.svc.Register(req.Email, req.Password, req.Name)
if err != nil {
httpx.Fail(w, r, http.StatusConflict, "USER_EXISTS", "user already exists")
return
}
httpx.OK(w, r, map[string]any{
"user": map[string]any{"id": u.ID, "email": u.Email, "name": u.Name},
})
}
type loginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
type loginResp struct {
AccessToken string `json:"access_token"`
AccessExpire time.Time `json:"access_expire"`
RefreshToken string `json:"refresh_token"`
RefreshExp time.Time `json:"refresh_expire"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_JSON", "invalid json")
return
}
at, aexp, rt, rexp, err := h.svc.Login(req.Email, req.Password)
if err != nil {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_BAD_CREDS", "invalid email or password")
return
}
httpx.OK(w, r, loginResp{
AccessToken: at,
AccessExpire: aexp,
RefreshToken: rt,
RefreshExp: rexp,
})
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
var req refreshReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_JSON", "invalid json")
return
}
at, exp, err := h.svc.Refresh(req.RefreshToken)
if err != nil {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REFRESH_FAILED", "refresh token invalid or expired")
return
}
httpx.OK(w, r, map[string]any{
"access_token": at,
"access_expire": exp,
})
}
type logoutReq struct {
RefreshToken string `json:"refresh_token"`
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
var req logoutReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_JSON", "invalid json")
return
}
h.svc.Logout(req.RefreshToken)
httpx.OK(w, r, map[string]any{"revoked": true})
}
7. User:Repo / Service / Handler
internal/user/model.go
package user
import (
"crypto/sha256"
"encoding/hex"
)
type User struct {
ID uint64
Email string
Name string
PasswordHash string
}
func HashPassword(pw string) string {
sum := sha256.Sum256([]byte(pw))
return hex.EncodeToString(sum[:])
}
func VerifyPassword(hash, pw string) bool {
return hash == HashPassword(pw)
}
internal/user/repo.go
package user
import (
"sync"
)
type Repo interface {
Create(email, password, name string) (User, error)
FindByID(id uint64) (User, bool)
FindByEmail(email string) (User, bool)
UpdateProfile(id uint64, name string) (User, bool)
}
type MemRepo struct {
mu sync.RWMutex
nextID uint64
byID map[uint64]User
byEmail map[string]uint64
}
func NewMemRepo() *MemRepo {
return &MemRepo{
nextID: 1000,
byID: make(map[uint64]User),
byEmail: make(map[string]uint64),
}
}
func (r *MemRepo) Create(email, password, name string) (User, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.byEmail[email]; ok {
return User{}, ErrExists
}
r.nextID++
u := User{
ID: r.nextID,
Email: email,
Name: name,
PasswordHash: HashPassword(password),
}
r.byID[u.ID] = u
r.byEmail[email] = u.ID
return u, nil
}
func (r *MemRepo) FindByID(id uint64) (User, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
u, ok := r.byID[id]
return u, ok
}
func (r *MemRepo) FindByEmail(email string) (User, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id, ok := r.byEmail[email]
if !ok {
return User{}, false
}
u, ok := r.byID[id]
return u, ok
}
func (r *MemRepo) UpdateProfile(id uint64, name string) (User, bool) {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.byID[id]
if !ok {
return User{}, false
}
u.Name = name
r.byID[id] = u
return u, true
}
internal/user/service.go
package user
type Service struct{ repo Repo }
func NewService(repo Repo) *Service { return &Service{repo: repo} }
func (s *Service) Me(id uint64) (User, bool) {
return s.repo.FindByID(id)
}
func (s *Service) UpdateMe(id uint64, name string) (User, bool) {
return s.repo.UpdateProfile(id, name)
}
internal/user/handler.go
package user
import (
"encoding/json"
"net/http"
"user-center/internal/auth"
"user-center/internal/httpx"
)
type Handler struct{ svc *Service }
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFrom(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "login required")
return
}
u, ok := h.svc.Me(uid)
if !ok {
httpx.Fail(w, r, http.StatusNotFound, "USER_NOT_FOUND", "user not found")
return
}
httpx.OK(w, r, map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name,
})
}
type updateMeReq struct {
Name string `json:"name"`
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFrom(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "login required")
return
}
var req updateMeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_JSON", "invalid json")
return
}
u, ok := h.svc.UpdateMe(uid, req.Name)
if !ok {
httpx.Fail(w, r, http.StatusNotFound, "USER_NOT_FOUND", "user not found")
return
}
httpx.OK(w, r, map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name,
})
}
8. Project:Repo / Service / Handler(仅归属当前用户)
这里用
:id路由参数。Plumego 的路由器支持/:param段。([GitHub][1])
参数读取方式在不同实现里可能不同(例如router.Param(r,"id")/ctx.Param("id"))。为避免误导,本文用一个保守方式:同时支持 query fallback,并在你的代码里统一改成 Plumego 的参数读取方法即可。
internal/project/model.go
package project
import "time"
type Project struct {
ID uint64
OwnerID uint64
Name string
CreatedAt time.Time
UpdatedAt time.Time
}
internal/project/repo.go
package project
import (
"sync"
"time"
)
type Repo interface {
Create(ownerID uint64, name string, now time.Time) Project
ListByOwner(ownerID uint64) []Project
Get(ownerID, id uint64) (Project, bool)
Update(ownerID, id uint64, name string, now time.Time) (Project, bool)
Delete(ownerID, id uint64) bool
}
type MemRepo struct {
mu sync.RWMutex
nextID uint64
byID map[uint64]Project
}
func NewMemRepo() *MemRepo {
return &MemRepo{nextID: 2000, byID: make(map[uint64]Project)}
}
func (r *MemRepo) Create(ownerID uint64, name string, now time.Time) Project {
r.mu.Lock()
defer r.mu.Unlock()
r.nextID++
p := Project{ID: r.nextID, OwnerID: ownerID, Name: name, CreatedAt: now, UpdatedAt: now}
r.byID[p.ID] = p
return p
}
func (r *MemRepo) ListByOwner(ownerID uint64) []Project {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Project, 0, 16)
for _, p := range r.byID {
if p.OwnerID == ownerID {
out = append(out, p)
}
}
return out
}
func (r *MemRepo) Get(ownerID, id uint64) (Project, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.byID[id]
return p, ok && p.OwnerID == ownerID
}
func (r *MemRepo) Update(ownerID, id uint64, name string, now time.Time) (Project, bool) {
r.mu.Lock()
defer r.mu.Unlock()
p, ok := r.byID[id]
if !ok || p.OwnerID != ownerID {
return Project{}, false
}
p.Name = name
p.UpdatedAt = now
r.byID[id] = p
return p, true
}
func (r *MemRepo) Delete(ownerID, id uint64) bool {
r.mu.Lock()
defer r.mu.Unlock()
p, ok := r.byID[id]
if !ok || p.OwnerID != ownerID {
return false
}
delete(r.byID, id)
return true
}
internal/project/service.go
package project
import "time"
type Service struct {
repo Repo
now func() time.Time
}
func NewService(repo Repo, now func() time.Time) *Service {
return &Service{repo: repo, now: now}
}
func (s *Service) Create(ownerID uint64, name string) Project {
return s.repo.Create(ownerID, name, s.now())
}
func (s *Service) List(ownerID uint64) []Project {
return s.repo.ListByOwner(ownerID)
}
func (s *Service) Get(ownerID, id uint64) (Project, bool) {
return s.repo.Get(ownerID, id)
}
func (s *Service) Update(ownerID, id uint64, name string) (Project, bool) {
return s.repo.Update(ownerID, id, name, s.now())
}
func (s *Service) Delete(ownerID, id uint64) bool {
return s.repo.Delete(ownerID, id)
}
internal/project/handler.go
package project
import (
"encoding/json"
"net/http"
"strconv"
"user-center/internal/auth"
"user-center/internal/httpx"
)
type Handler struct{ svc *Service }
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
type createReq struct {
Name string `json:"name"`
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFrom(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "login required")
return
}
var req createReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_JSON", "invalid json")
return
}
p := h.svc.Create(uid, req.Name)
httpx.OK(w, r, p)
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFrom(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "login required")
return
}
httpx.OK(w, r, map[string]any{"items": h.svc.List(uid)})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFrom(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "login required")
return
}
id, err := parseID(r)
if err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_ID", "invalid id")
return
}
p, ok := h.svc.Get(uid, id)
if !ok {
httpx.Fail(w, r, http.StatusNotFound, "PROJECT_NOT_FOUND", "project not found")
return
}
httpx.OK(w, r, p)
}
type updateReq struct {
Name string `json:"name"`
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFrom(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "login required")
return
}
id, err := parseID(r)
if err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_ID", "invalid id")
return
}
var req updateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_JSON", "invalid json")
return
}
p, ok := h.svc.Update(uid, id, req.Name)
if !ok {
httpx.Fail(w, r, http.StatusNotFound, "PROJECT_NOT_FOUND", "project not found")
return
}
httpx.OK(w, r, p)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
uid, ok := auth.UserIDFrom(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "login required")
return
}
id, err := parseID(r)
if err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "REQ_INVALID_ID", "invalid id")
return
}
if !h.svc.Delete(uid, id) {
httpx.Fail(w, r, http.StatusNotFound, "PROJECT_NOT_FOUND", "project not found")
return
}
httpx.OK(w, r, map[string]any{"deleted": true})
}
// TODO: 将这里替换为 Plumego router 的 param 读取方式(例如 ctx.Param("id"))
func parseID(r *http.Request) (uint64, error) {
// 兼容:优先 query ?id=,便于你先跑通;接入 param 后删掉这段
if qs := r.URL.Query().Get("id"); qs != "" {
v, err := strconv.ParseUint(qs, 10, 64)
return v, err
}
// 如果你的 router 暴露 path param,可在这里替换实现
return 0, strconv.ErrSyntax
}
9. 应用装配:把模块挂到 Plumego App 上
internal/app/wiring.go
package app
import (
"net/http"
"time"
"github.com/spcent/plumego/core"
"user-center/internal/auth"
"user-center/internal/httpx"
"user-center/internal/project"
"user-center/internal/user"
)
func Wire(a *core.App) {
// 全局:Request-ID(用于排障/日志串联)
// 说明:如果 core.App 暴露 app.Use(...),你可以改为 app.Use(httpx.EnsureRequestID)
// 这里用“显式包裹”的方式,避免依赖未展示的 API。
wrap := func(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
httpx.EnsureRequestID(http.HandlerFunc(h)).ServeHTTP(w, r)
}
}
now := time.Now
// Repos
userRepo := user.NewMemRepo()
projectRepo := project.NewMemRepo()
refreshRepo := auth.NewMemRefreshRepo()
// Services
tokenSvc := auth.NewTokenService("dev-secret-change-me", 15*time.Minute)
authSvc := auth.NewService(userRepo, tokenSvc, refreshRepo, 14*24*time.Hour, now)
userSvc := user.NewService(userRepo)
projectSvc := project.NewService(projectRepo, now)
// Handlers
authH := auth.NewHandler(authSvc)
userH := user.NewHandler(userSvc)
projectH := project.NewHandler(projectSvc)
// Auth routes
a.Post("/api/v1/auth/register", wrap(authH.Register))
a.Post("/api/v1/auth/login", wrap(authH.Login))
a.Post("/api/v1/auth/refresh", wrap(authH.Refresh))
a.Post("/api/v1/auth/logout", wrap(authH.Logout))
// Protected routes(显式组合:RequireAuth(tokenSvc)(handler))
require := auth.RequireAuth(tokenSvc, now)
// User
a.Get("/api/v1/users/me", func(w http.ResponseWriter, r *http.Request) {
require(http.HandlerFunc(wrap(userH.Me))).ServeHTTP(w, r)
})
a.Put("/api/v1/users/me", func(w http.ResponseWriter, r *http.Request) {
require(http.HandlerFunc(wrap(userH.UpdateMe))).ServeHTTP(w, r)
})
// Project
a.Post("/api/v1/projects", func(w http.ResponseWriter, r *http.Request) {
require(http.HandlerFunc(wrap(projectH.Create))).ServeHTTP(w, r)
})
a.Get("/api/v1/projects", func(w http.ResponseWriter, r *http.Request) {
require(http.HandlerFunc(wrap(projectH.List))).ServeHTTP(w, r)
})
a.Get("/api/v1/projects/:id", func(w http.ResponseWriter, r *http.Request) {
require(http.HandlerFunc(wrap(projectH.Get))).ServeHTTP(w, r)
})
a.Put("/api/v1/projects/:id", func(w http.ResponseWriter, r *http.Request) {
require(http.HandlerFunc(wrap(projectH.Update))).ServeHTTP(w, r)
})
a.Delete("/api/v1/projects/:id", func(w http.ResponseWriter, r *http.Request) {
require(http.HandlerFunc(wrap(projectH.Delete))).ServeHTTP(w, r)
})
// Health
a.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
httpx.OK(w, r, map[string]any{"status": "ok"})
})
}
你会注意到这里刻意保持“显式组合”,这与 Plumego 的设计取向一致:它鼓励你把组件当作标准库 handler 去拼装,而不是依赖隐藏生命周期。([GitHub][1])
10. 最小验证(curl)
# 1) 注册
curl -sS -X POST http://localhost:8080/api/v1/auth/register \
-H 'content-type: application/json' \
-d '{"email":"a@b.com","password":"123456","name":"Alice"}' | jq
# 2) 登录
LOGIN=$(curl -sS -X POST http://localhost:8080/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"email":"a@b.com","password":"123456"}')
AT=$(echo "$LOGIN" | jq -r '.data.access_token')
RT=$(echo "$LOGIN" | jq -r '.data.refresh_token')
# 3) me
curl -sS http://localhost:8080/api/v1/users/me \
-H "authorization: Bearer $AT" | jq
# 4) 创建项目
curl -sS -X POST http://localhost:8080/api/v1/projects \
-H "authorization: Bearer $AT" \
-H 'content-type: application/json' \
-d '{"name":"Birdor Console"}' | jq
# 5) refresh
curl -sS -X POST http://localhost:8080/api/v1/auth/refresh \
-H 'content-type: application/json' \
-d "{\"refresh_token\":\"$RT\"}" | jq
11. 下一步(把“示例”升级为“生产骨架”)
- 把 Project 的
:id参数读取替换为 Plumego Router 的原生方式(你在 router 包里能直接看到具体 API;改动只在parseID一处)。 - 把 MemRepo 替换为 store(DB/Redis)实现:接口不变,模块无需改动。
- 引入更严格的验证与错误码表:把
REQ_* / AUTH_* / USER_* / PROJECT_*固化为常量,并写一份ERRORS.md给前端/调用方。 - 接入 Plumego 的组件化能力(core.Component):将 user/auth/project 变为 component,做到“插拔式业务模块”。([GitHub][1])
参考
[1]: Plumego README(Quick start、组件化、路由参数能力等) “GitHub - spcent/plumego: plumego is a minimalist web framework built entirely with the Go standard library, with zero external dependencies. It is designed to be simple, elegant, and efficient, making it ideal for small to medium projects or as a solid foundation for learning Go web development.”