diff --git a/internal/user/handler.go b/internal/user/handler.go new file mode 100644 index 0000000..b7c6248 --- /dev/null +++ b/internal/user/handler.go @@ -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):]) +} diff --git a/internal/user/handler_test.go b/internal/user/handler_test.go new file mode 100644 index 0000000..68b8e88 --- /dev/null +++ b/internal/user/handler_test.go @@ -0,0 +1,101 @@ +package user + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// mountHandler builds a chi router with the user Handler attached. Helper +// is shared by every handler test so the route prefix /api/v1/auth lives in +// exactly one place; if the URL contract ever shifts the change is local. +func mountHandler(svc Service) http.Handler { + r := chi.NewRouter() + h := NewHandler(svc) + h.Mount(r) + return r +} + +// TestHandler_LoginThenMeThenLogout walks the full happy path of the auth +// surface: log in, hit /me with the issued token, log out, and confirm the +// token is no longer accepted. It exercises the integration between Handler, +// Service, the in-memory fakeRepo and the Auth middleware. +func TestHandler_LoginThenMeThenLogout(t *testing.T) { + repo := newFakeRepo() + hash, err := HashPassword("password123") + require.NoError(t, err) + repo.users["a@b.c"] = &User{ + ID: uuid.New(), + Email: "a@b.c", + DisplayName: "T", + PasswordHash: hash, + } + svc := NewService(repo) + srv := httptest.NewServer(mountHandler(svc)) + defer srv.Close() + + // 1. login + body, err := json.Marshal(map[string]string{"email": "a@b.c", "password": "password123"}) + require.NoError(t, err) + resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + var loginOut struct { + Token string `json:"token"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"user"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&loginOut)) + require.NoError(t, resp.Body.Close()) + require.NotEmpty(t, loginOut.Token) + require.Equal(t, "a@b.c", loginOut.User.Email) + + // 2. me + req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/auth/me", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+loginOut.Token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + + // 3. logout + req, err = http.NewRequest(http.MethodPost, srv.URL+"/api/v1/auth/logout", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+loginOut.Token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + + // 4. me again -> 401: the same bearer must be rejected after logout. + req, err = http.NewRequest(http.MethodGet, srv.URL+"/api/v1/auth/me", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+loginOut.Token) + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.NoError(t, resp.Body.Close()) +} + +// TestHandler_LoginInvalidPayload covers the validation branch: an empty +// JSON object lacks both email and password and must be rejected with 400 +// before the service layer is consulted. +func TestHandler_LoginInvalidPayload(t *testing.T) { + svc := NewService(newFakeRepo()) + srv := httptest.NewServer(mountHandler(svc)) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader([]byte("{}"))) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + require.NoError(t, resp.Body.Close()) +}