This commit is contained in:
2026-06-09 21:19:30 +08:00
parent 8b41ff9360
commit 0484e79978
66 changed files with 4043 additions and 533 deletions
+303 -1
View File
@@ -18,8 +18,10 @@ 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"
@@ -42,15 +44,47 @@ 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 才会走到下面的处理函数。
auth := middleware.Auth(h.svc, middleware.AuthOptions{})
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 的请求体形态。
@@ -161,3 +195,271 @@ func clientIP(r *http.Request) string {
}
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)
}