You've already forked agentic-coding-workflow
feat(user): http handler for login/me/logout with middleware integration
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
// Package user 内的 handler.go 把 Service 暴露为 HTTP 端点:登录、读取
|
||||
// 当前用户信息、登出。这一层只做协议适配——解析请求、调用 Service、
|
||||
// 把结果编码成 JSON——业务逻辑全部下沉到 service.go 中。
|
||||
//
|
||||
// 设计取舍:
|
||||
// - 路由前缀 /api/v1/auth 写死在 Mount 内:版本号是契约的一部分,调用方
|
||||
// 不需要也不应该自定义;如果未来要支持多版本并行,再在 Mount 之上做分发。
|
||||
// - /me 与 /logout 都挂在 middleware.Auth 之后;/login 必须裸跑,否则永远
|
||||
// 无法首次拿到 token。
|
||||
// - Service 接口已经满足 middleware.SessionResolver(同名同签名方法),
|
||||
// 因此直接把 svc 传进 Auth,无需写额外的适配器。
|
||||
// - bearerFrom 在本包重复了 middleware.extractBearer 的逻辑:后者是包私有
|
||||
// 函数,不导出;与其为一个 8 行的字符串解析改动 middleware 的 API 表面,
|
||||
// 不如在调用侧自己拆一遍 Authorization 头。
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// bearerPrefix 是 RFC 6750 规定的 Authorization 方案前缀。
|
||||
const bearerPrefix = "Bearer "
|
||||
|
||||
// Handler 把 user.Service 暴露为 chi 路由。字段保持小写避免外部直接构造,
|
||||
// 调用方必须通过 NewHandler 注入实现。
|
||||
type Handler struct {
|
||||
svc Service
|
||||
}
|
||||
|
||||
// NewHandler 用给定的 Service 构造 Handler。返回 *Handler 以便 Mount 这种
|
||||
// 仅需值接收者的方法可以直接调用,无需暴露接口扩展点(目前没有第二种实现)。
|
||||
func NewHandler(svc Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
// Mount 把 user 模块的所有 HTTP 端点挂到给定 router 的 /api/v1/auth 子树。
|
||||
// 调用方负责在挂载之前装好 RequestID/Recover/Logger 等通用中间件。
|
||||
func (h *Handler) Mount(r chi.Router) {
|
||||
r.Route("/api/v1/auth", func(r chi.Router) {
|
||||
r.Post("/login", h.login)
|
||||
|
||||
// /me 与 /logout 必须经过 Auth 中间件:未带 token 直接 401,由中间件
|
||||
// 落地响应;带了 token 才会走到下面的处理函数。
|
||||
auth := middleware.Auth(h.svc, middleware.AuthOptions{})
|
||||
r.With(auth).Get("/me", h.me)
|
||||
r.With(auth).Post("/logout", h.logout)
|
||||
})
|
||||
}
|
||||
|
||||
// loginReq 是 POST /api/v1/auth/login 的请求体形态。
|
||||
type loginReq struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// loginResp 是登录成功后的响应形态。token 仅此一次返回明文。
|
||||
type loginResp struct {
|
||||
Token string `json:"token"`
|
||||
User userDTO `json:"user"`
|
||||
}
|
||||
|
||||
// userDTO 是对外暴露的用户视图。刻意不直接序列化 *User,避免哈希、时间等
|
||||
// 服务端字段意外随响应外泄。
|
||||
type userDTO struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// toUserDTO 把领域 User 转成响应视图。集中此处便于 /login 与 /me 共用。
|
||||
func toUserDTO(u *User) userDTO {
|
||||
return userDTO{
|
||||
ID: u.ID.String(),
|
||||
Email: u.Email,
|
||||
DisplayName: u.DisplayName,
|
||||
IsAdmin: u.IsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
|
||||
var req loginReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" || req.Password == "" {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "缺少邮箱或密码"))
|
||||
return
|
||||
}
|
||||
|
||||
tok, u, err := h.svc.Login(r.Context(), req.Email, req.Password, r.RemoteAddr)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, loginResp{Token: tok, User: toUserDTO(u)})
|
||||
}
|
||||
|
||||
func (h *Handler) me(w http.ResponseWriter, r *http.Request) {
|
||||
rid := middleware.RequestIDFromContext(r.Context())
|
||||
|
||||
// Auth 中间件已经保证 user_id 必然存在;ok=false 仅在 Optional 模式或
|
||||
// 缺失中间件时出现,这里都不应当发生,按内部错误处理避免静默返回 0 值。
|
||||
uid, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
httpx.WriteError(w, rid, errs.New(errs.CodeInternal, "missing auth context"))
|
||||
return
|
||||
}
|
||||
u, err := h.svc.Get(r.Context(), uid)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, rid, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, toUserDTO(u))
|
||||
}
|
||||
|
||||
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
// Service.Logout 对未知 token 也返回 nil,因此即便 token 已被并发清理,
|
||||
// 这里也能给客户端一个一致的 204 响应。
|
||||
if err := h.svc.Logout(r.Context(), bearerFrom(r)); err != nil {
|
||||
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// bearerFrom 解析 Authorization 头的 Bearer 部分并返回明文 token。
|
||||
// 行为与 middleware.extractBearer 一致:缺头或前缀不匹配时返回空串。
|
||||
func bearerFrom(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, bearerPrefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(bearerPrefix):])
|
||||
}
|
||||
Reference in New Issue
Block a user