// 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" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "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) { auth := middleware.Auth(h.svc, middleware.AuthOptions{}) r.Route("/api/v1/auth", func(r chi.Router) { r.Post("/login", h.login) // /me 与 /logout 必须经过 Auth 中间件:未带 token 直接 401,由中间件 // 落地响应;带了 token 才会走到下面的处理函数。 r.With(auth).Get("/me", h.me) r.With(auth).Post("/logout", h.logout) // 自助改密:需认证,校验旧密码后撤销全部会话。 r.With(auth).Post("/change-password", h.changePassword) }) // 管理员用户管理子树:全部经 Auth,handler 内部再用 requireAdmin 做 admin 鉴权。 r.Route("/api/v1/admin/users", func(r chi.Router) { r.Use(auth) r.Get("/", h.listUsers) r.Post("/", h.createUser) r.Get("/{id}", h.getUser) r.Patch("/{id}", h.updateUser) r.Delete("/{id}", h.deleteUser) r.Patch("/{id}/role", h.setRole) r.Patch("/{id}/enabled", h.setEnabled) r.Post("/{id}/reset-password", h.resetPassword) }) } // requireAdmin 解析当前认证用户并校验其为管理员。返回 actor 的 UserID。 func (h *Handler) requireAdmin(r *http.Request) (uuid.UUID, error) { uid, ok := middleware.UserIDFromContext(r.Context()) if !ok { return uuid.Nil, errs.New(errs.CodeUnauthorized, "not authenticated") } u, err := h.svc.Get(r.Context(), uid) if err != nil { return uuid.Nil, err } if !u.IsAdmin { return uuid.Nil, errs.New(errs.CodeForbidden, "需要管理员权限") } return uid, nil } // 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, clientIP(r)) 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):]) } // clientIP 从请求头提取最接近真实客户端的 IP。优先级:X-Forwarded-For 首段 // > X-Real-IP > r.RemoteAddr。 // // **安全提示**:当前未实现 trusted proxy 名单,X-Forwarded-For/X-Real-IP 可 // 被任意客户端伪造。生产部署必须在反向代理层 strip 客户端送来的这些头,仅 // 由代理本身写入;否则审计 IP 不可信。该问题在引入 ingress 配置时一并处理。 func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if comma := strings.IndexByte(xff, ','); comma > 0 { return strings.TrimSpace(xff[:comma]) } return strings.TrimSpace(xff) } if xri := r.Header.Get("X-Real-IP"); xri != "" { return strings.TrimSpace(xri) } return r.RemoteAddr } // ===== 管理员用户管理 ===== // adminUserDTO 是管理后台的用户视图,比 userDTO 多 enabled 与时间戳,但同样 // 不暴露 password_hash。 type adminUserDTO struct { ID string `json:"id"` Email string `json:"email"` DisplayName string `json:"display_name"` IsAdmin bool `json:"is_admin"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func toAdminUserDTO(u *User) adminUserDTO { return adminUserDTO{ ID: u.ID.String(), Email: u.Email, DisplayName: u.DisplayName, IsAdmin: u.IsAdmin, Enabled: u.Enabled, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, } } // userIDParam 解析路由中的 {id} 为 uuid。 func userIDParam(r *http.Request) (uuid.UUID, error) { id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { return uuid.Nil, errs.New(errs.CodeInvalidInput, "无效的用户 id") } return id, nil } func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) if _, err := h.requireAdmin(r); err != nil { httpx.WriteError(w, rid, err) return } users, err := h.svc.ListUsers(r.Context()) if err != nil { httpx.WriteError(w, rid, err) return } out := make([]adminUserDTO, 0, len(users)) for _, u := range users { out = append(out, toAdminUserDTO(u)) } httpx.WriteJSON(w, http.StatusOK, out) } type createUserReq struct { Email string `json:"email"` DisplayName string `json:"display_name"` Password string `json:"password"` IsAdmin bool `json:"is_admin"` } func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) if _, err := h.requireAdmin(r); err != nil { httpx.WriteError(w, rid, err) return } var req createUserReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) return } email := strings.TrimSpace(req.Email) display := strings.TrimSpace(req.DisplayName) if email == "" || display == "" { httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "缺少邮箱或显示名")) return } u, err := h.svc.CreateUser(r.Context(), CreateUserInput{ Email: email, DisplayName: display, Password: req.Password, IsAdmin: req.IsAdmin, }) if err != nil { httpx.WriteError(w, rid, err) return } httpx.WriteJSON(w, http.StatusCreated, toAdminUserDTO(u)) } func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) if _, err := h.requireAdmin(r); err != nil { httpx.WriteError(w, rid, err) return } id, err := userIDParam(r) if err != nil { httpx.WriteError(w, rid, err) return } u, err := h.svc.Get(r.Context(), id) if err != nil { httpx.WriteError(w, rid, err) return } httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) } type updateUserReq struct { DisplayName string `json:"display_name"` } func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) if _, err := h.requireAdmin(r); err != nil { httpx.WriteError(w, rid, err) return } id, err := userIDParam(r) if err != nil { httpx.WriteError(w, rid, err) return } var req updateUserReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) return } display := strings.TrimSpace(req.DisplayName) if display == "" { httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "显示名不能为空")) return } u, err := h.svc.UpdateProfile(r.Context(), id, display) if err != nil { httpx.WriteError(w, rid, err) return } httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) } func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) actor, err := h.requireAdmin(r) if err != nil { httpx.WriteError(w, rid, err) return } id, err := userIDParam(r) if err != nil { httpx.WriteError(w, rid, err) return } if err := h.svc.DeleteUser(r.Context(), actor, id); err != nil { httpx.WriteError(w, rid, err) return } w.WriteHeader(http.StatusNoContent) } type setRoleReq struct { IsAdmin bool `json:"is_admin"` } func (h *Handler) setRole(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) actor, err := h.requireAdmin(r) if err != nil { httpx.WriteError(w, rid, err) return } id, err := userIDParam(r) if err != nil { httpx.WriteError(w, rid, err) return } var req setRoleReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) return } u, err := h.svc.SetAdmin(r.Context(), actor, id, req.IsAdmin) if err != nil { httpx.WriteError(w, rid, err) return } httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) } type setEnabledReq struct { Enabled bool `json:"enabled"` } func (h *Handler) setEnabled(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) actor, err := h.requireAdmin(r) if err != nil { httpx.WriteError(w, rid, err) return } id, err := userIDParam(r) if err != nil { httpx.WriteError(w, rid, err) return } var req setEnabledReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) return } u, err := h.svc.SetEnabled(r.Context(), actor, id, req.Enabled) if err != nil { httpx.WriteError(w, rid, err) return } httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) } type resetPasswordResp struct { TempPassword string `json:"temp_password"` } func (h *Handler) resetPassword(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) if _, err := h.requireAdmin(r); err != nil { httpx.WriteError(w, rid, err) return } id, err := userIDParam(r) if err != nil { httpx.WriteError(w, rid, err) return } temp, err := h.svc.AdminResetPassword(r.Context(), id) if err != nil { httpx.WriteError(w, rid, err) return } httpx.WriteJSON(w, http.StatusOK, resetPasswordResp{TempPassword: temp}) } // ===== 自助改密 ===== type changePasswordReq struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` } func (h *Handler) changePassword(w http.ResponseWriter, r *http.Request) { rid := middleware.RequestIDFromContext(r.Context()) uid, ok := middleware.UserIDFromContext(r.Context()) if !ok { httpx.WriteError(w, rid, errs.New(errs.CodeInternal, "missing auth context")) return } var req changePasswordReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) return } if err := h.svc.ChangePassword(r.Context(), uid, req.OldPassword, req.NewPassword); err != nil { httpx.WriteError(w, rid, err) return } w.WriteHeader(http.StatusNoContent) }