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
+30
View File
@@ -22,6 +22,7 @@ type User struct {
Email string
DisplayName string
IsAdmin bool
Enabled bool
PasswordHash string
CreatedAt time.Time
UpdatedAt time.Time
@@ -58,4 +59,33 @@ type Service interface {
Bootstrap(ctx context.Context, email, password string) (*User, error)
// ListAdmins 返回所有管理员用户,供内部模块(如 jobs runner)使用。
ListAdmins(ctx context.Context) ([]*User, error)
// ===== 管理员用户管理(需调用方自行做 admin 鉴权)=====
// ListUsers 返回全部用户,按创建时间升序,供管理后台展示。
ListUsers(ctx context.Context) ([]*User, error)
// CreateUser 由管理员创建一个新账号。email 唯一冲突时返回 CodeConflict。
CreateUser(ctx context.Context, in CreateUserInput) (*User, error)
// UpdateProfile 修改用户的 display_name。
UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error)
// SetAdmin 设置用户的管理员标志。降级最后一个启用管理员时返回 CodeConflict;
// actor==target 且降级自身时返回 CodeConflict(防自锁)。
SetAdmin(ctx context.Context, actor, target uuid.UUID, isAdmin bool) (*User, error)
// SetEnabled 启用/禁用用户。禁用会撤销其全部会话。禁用最后一个启用管理员或
// 禁用自身时返回 CodeConflict。
SetEnabled(ctx context.Context, actor, target uuid.UUID, enabled bool) (*User, error)
// AdminResetPassword 由管理员重置目标用户密码,返回一次性明文临时密码并撤销其会话。
AdminResetPassword(ctx context.Context, id uuid.UUID) (tempPassword string, err error)
// ChangePassword 用户自助改密:校验旧密码后更新,并撤销其它会话。
ChangePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error
// DeleteUser 删除用户。删除最后一个启用管理员或删除自身时返回 CodeConflict。
DeleteUser(ctx context.Context, actor, target uuid.UUID) error
}
// CreateUserInput 是管理员创建用户的入参。
type CreateUserInput struct {
Email string
DisplayName string
Password string
IsAdmin bool
}
+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)
}
+47 -8
View File
@@ -1,30 +1,66 @@
-- name: CreateUser :one
INSERT INTO users (id, email, password_hash, display_name, is_admin)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at;
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
-- name: GetUserByEmail :one
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
WHERE email = $1;
-- name: GetUserByID :one
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
WHERE id = $1;
-- name: ListUsers :many
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
ORDER BY created_at ASC;
-- name: CountUsers :one
SELECT COUNT(*)::int FROM users;
-- name: CountAdmins :one
SELECT COUNT(*)::int FROM users WHERE is_admin = true AND enabled = true;
-- name: UpdateUserProfile :one
UPDATE users
SET display_name = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
-- name: SetUserAdmin :one
UPDATE users
SET is_admin = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
-- name: SetUserEnabled :one
UPDATE users
SET enabled = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled;
-- name: UpdateUserPassword :exec
UPDATE users
SET password_hash = $2, updated_at = now()
WHERE id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
-- name: CreateSession :exec
INSERT INTO user_sessions (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4);
-- name: GetSessionByTokenHash :one
SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at
FROM user_sessions
WHERE token_hash = $1
AND expires_at > now();
SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at
FROM user_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
AND s.expires_at > now()
AND u.enabled = true;
-- name: TouchSession :exec
UPDATE user_sessions
@@ -34,11 +70,14 @@ WHERE token_hash = $1;
-- name: DeleteSessionByTokenHash :exec
DELETE FROM user_sessions WHERE token_hash = $1;
-- name: DeleteSessionsByUserID :exec
DELETE FROM user_sessions WHERE user_id = $1;
-- name: DeleteExpiredSessions :exec
DELETE FROM user_sessions WHERE expires_at <= now();
-- name: ListAdmins :many
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
WHERE is_admin = true
ORDER BY id;
+114
View File
@@ -20,7 +20,9 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
@@ -35,12 +37,20 @@ type Repository interface {
GetUserByEmail(ctx context.Context, email string) (*User, error)
GetUserByID(ctx context.Context, id uuid.UUID) (*User, error)
CountUsers(ctx context.Context) (int, error)
CountAdmins(ctx context.Context) (int, error)
ListUsers(ctx context.Context) ([]*User, error)
ListAdmins(ctx context.Context) ([]*User, error)
UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error)
SetAdmin(ctx context.Context, id uuid.UUID, isAdmin bool) (*User, error)
SetEnabled(ctx context.Context, id uuid.UUID, enabled bool) (*User, error)
UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error
DeleteUser(ctx context.Context, id uuid.UUID) error
CreateSession(ctx context.Context, s *Session) error
GetSessionByTokenHash(ctx context.Context, hash []byte) (*Session, error)
TouchSession(ctx context.Context, hash []byte) error
DeleteSessionByTokenHash(ctx context.Context, hash []byte) error
DeleteSessionsByUserID(ctx context.Context, userID uuid.UUID) error
}
// pgRepo 是 Repository 的 Postgres 实现,内部持有一个 sqlc 生成的 Queries。
@@ -80,6 +90,7 @@ func rowToUser(row usersqlc.User) *User {
PasswordHash: row.PasswordHash,
DisplayName: row.DisplayName,
IsAdmin: row.IsAdmin,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
@@ -114,11 +125,20 @@ func (r *pgRepo) CreateUser(ctx context.Context, u *User) (*User, error) {
IsAdmin: u.IsAdmin,
})
if err != nil {
if isUniqueViolation(err) {
return nil, errs.New(errs.CodeConflict, "邮箱已被占用")
}
return nil, errs.Wrap(err, errs.CodeInternal, "create user")
}
return rowToUser(row), nil
}
// isUniqueViolation 判断底层错误是否为 Postgres 唯一约束冲突(如 email 重复)。
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
}
// GetUserByEmail 按邮箱精确查询用户。pgx.ErrNoRows 翻译为 NotFound,方便
// 上层(例如登录流程)分类响应;其它错误一律 Internal。
func (r *pgRepo) GetUserByEmail(ctx context.Context, email string) (*User, error) {
@@ -212,3 +232,97 @@ func (r *pgRepo) ListAdmins(ctx context.Context) ([]*User, error) {
}
return out, nil
}
// CountAdmins 返回启用状态的管理员数量,用于 service 层的“最后一个管理员”保护。
func (r *pgRepo) CountAdmins(ctx context.Context) (int, error) {
n, err := r.q.CountAdmins(ctx)
if err != nil {
return 0, errs.Wrap(err, errs.CodeInternal, "count admins")
}
return int(n), nil
}
// ListUsers 返回全部用户,按创建时间升序。
func (r *pgRepo) ListUsers(ctx context.Context) ([]*User, error) {
rows, err := r.q.ListUsers(ctx)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "list users")
}
out := make([]*User, 0, len(rows))
for _, row := range rows {
out = append(out, rowToUser(row))
}
return out, nil
}
// UpdateProfile 更新 display_name 并返回更新后的行。目标不存在时返回 NotFound。
func (r *pgRepo) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) {
row, err := r.q.UpdateUserProfile(ctx, usersqlc.UpdateUserProfileParams{
ID: toPgUUID(id),
DisplayName: displayName,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "用户不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "update user profile")
}
return rowToUser(row), nil
}
// SetAdmin 设置 is_admin 并返回更新后的行。目标不存在时返回 NotFound。
func (r *pgRepo) SetAdmin(ctx context.Context, id uuid.UUID, isAdmin bool) (*User, error) {
row, err := r.q.SetUserAdmin(ctx, usersqlc.SetUserAdminParams{
ID: toPgUUID(id),
IsAdmin: isAdmin,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "用户不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "set user admin")
}
return rowToUser(row), nil
}
// SetEnabled 设置 enabled 并返回更新后的行。目标不存在时返回 NotFound。
func (r *pgRepo) SetEnabled(ctx context.Context, id uuid.UUID, enabled bool) (*User, error) {
row, err := r.q.SetUserEnabled(ctx, usersqlc.SetUserEnabledParams{
ID: toPgUUID(id),
Enabled: enabled,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "用户不存在")
}
return nil, errs.Wrap(err, errs.CodeInternal, "set user enabled")
}
return rowToUser(row), nil
}
// UpdatePassword 覆写密码哈希。:exec 不读结果集,目标不存在不报错(幂等)。
func (r *pgRepo) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error {
if err := r.q.UpdateUserPassword(ctx, usersqlc.UpdateUserPasswordParams{
ID: toPgUUID(id),
PasswordHash: passwordHash,
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "update user password")
}
return nil
}
// DeleteUser 删除用户。其会话经 ON DELETE CASCADE 一并清除。
func (r *pgRepo) DeleteUser(ctx context.Context, id uuid.UUID) error {
if err := r.q.DeleteUser(ctx, toPgUUID(id)); err != nil {
return errs.Wrap(err, errs.CodeInternal, "delete user")
}
return nil
}
// DeleteSessionsByUserID 撤销某用户的全部会话(改密/禁用时使用)。
func (r *pgRepo) DeleteSessionsByUserID(ctx context.Context, userID uuid.UUID) error {
if err := r.q.DeleteSessionsByUserID(ctx, toPgUUID(userID)); err != nil {
return errs.Wrap(err, errs.CodeInternal, "delete sessions by user id")
}
return nil
}
+199
View File
@@ -177,3 +177,202 @@ func (s *service) Bootstrap(ctx context.Context, email, password string) (*User,
func (s *service) ListAdmins(ctx context.Context) ([]*User, error) {
return s.repo.ListAdmins(ctx)
}
// tempPasswordLen 是管理员重置密码时生成的临时明文长度(base64 字符数)。
// 16 字符远超 minPasswordLen,且使用与会话 token 相同的强随机源。
const tempPasswordLen = 16
// ListUsers 返回全部用户,供管理后台展示。调用方负责 admin 鉴权。
func (s *service) ListUsers(ctx context.Context) ([]*User, error) {
return s.repo.ListUsers(ctx)
}
// CreateUser 由管理员创建新账号。密码长度由 HashPassword 校验;email 冲突时
// Repository 返回 CodeConflict。
func (s *service) CreateUser(ctx context.Context, in CreateUserInput) (*User, error) {
hash, err := HashPassword(in.Password)
if err != nil {
return nil, errs.Wrap(err, errs.CodeInvalidInput, "password")
}
u := &User{
ID: uuid.New(),
Email: in.Email,
PasswordHash: hash,
DisplayName: in.DisplayName,
IsAdmin: in.IsAdmin,
}
out, err := s.repo.CreateUser(ctx, u)
if err != nil {
return nil, err
}
s.record(ctx, &out.ID, "user.create", out.ID.String())
return out, nil
}
// UpdateProfile 修改用户 display_name。
func (s *service) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) {
out, err := s.repo.UpdateProfile(ctx, id, displayName)
if err != nil {
return nil, err
}
s.record(ctx, &id, "user.update_profile", id.String())
return out, nil
}
// SetAdmin 调整用户管理员标志。两条护栏:actor 不能降级自己(防误操作把自己
// 锁在门外);不能降级最后一个启用管理员(防系统失去管理员)。
func (s *service) SetAdmin(ctx context.Context, actor, target uuid.UUID, isAdmin bool) (*User, error) {
if !isAdmin {
if actor == target {
return nil, errs.New(errs.CodeConflict, "不能取消自己的管理员权限")
}
if err := s.guardLastAdmin(ctx, target); err != nil {
return nil, err
}
}
out, err := s.repo.SetAdmin(ctx, target, isAdmin)
if err != nil {
return nil, err
}
s.record(ctx, &actor, "user.set_admin", target.String())
return out, nil
}
// SetEnabled 启用/禁用用户。禁用时撤销其全部会话使其立即下线,并施加与
// SetAdmin 同样的自锁/最后管理员护栏。
func (s *service) SetEnabled(ctx context.Context, actor, target uuid.UUID, enabled bool) (*User, error) {
if !enabled {
if actor == target {
return nil, errs.New(errs.CodeConflict, "不能禁用自己")
}
if err := s.guardLastAdmin(ctx, target); err != nil {
return nil, err
}
}
out, err := s.repo.SetEnabled(ctx, target, enabled)
if err != nil {
return nil, err
}
if !enabled {
// best-effort:撤销会话失败不应让禁用操作回滚(enabled=false 已生效,
// ResolveSession 会拒绝该用户)。
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), target)
}
s.record(ctx, &actor, "user.set_enabled", target.String())
return out, nil
}
// AdminResetPassword 由管理员重置目标密码,生成强随机临时密码、撤销其全部
// 会话,并返回临时明文(仅此一次)。
func (s *service) AdminResetPassword(ctx context.Context, id uuid.UUID) (string, error) {
if _, err := s.repo.GetUserByID(ctx, id); err != nil {
return "", err
}
temp, err := randomPassword()
if err != nil {
return "", errs.Wrap(err, errs.CodeInternal, "generate temp password")
}
hash, err := HashPassword(temp)
if err != nil {
return "", errs.Wrap(err, errs.CodeInternal, "hash temp password")
}
if err := s.repo.UpdatePassword(ctx, id, hash); err != nil {
return "", err
}
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id)
s.record(ctx, &id, "user.reset_password", id.String())
return temp, nil
}
// ChangePassword 用户自助改密:校验旧密码后更新,并撤销其全部会话(含当前),
// 强制用新密码重新登录。
func (s *service) ChangePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error {
u, err := s.repo.GetUserByID(ctx, id)
if err != nil {
return err
}
ok, err := VerifyPassword(oldPassword, u.PasswordHash)
if err != nil {
return errs.Wrap(err, errs.CodeInternal, "verify password")
}
if !ok {
return errs.New(errs.CodeUnauthorized, "旧密码错误")
}
hash, err := HashPassword(newPassword)
if err != nil {
return errs.Wrap(err, errs.CodeInvalidInput, "password")
}
if err := s.repo.UpdatePassword(ctx, id, hash); err != nil {
return err
}
_ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id)
s.record(ctx, &id, "user.change_password", id.String())
return nil
}
// DeleteUser 删除用户。同样施加自锁与最后管理员护栏。其会话经 DB 外键级联清除。
func (s *service) DeleteUser(ctx context.Context, actor, target uuid.UUID) error {
if actor == target {
return errs.New(errs.CodeConflict, "不能删除自己")
}
t, err := s.repo.GetUserByID(ctx, target)
if err != nil {
return err
}
if t.IsAdmin && t.Enabled {
if err := s.guardLastAdmin(ctx, target); err != nil {
return err
}
}
if err := s.repo.DeleteUser(ctx, target); err != nil {
return err
}
s.record(ctx, &actor, "user.delete", target.String())
return nil
}
// guardLastAdmin 在降级/禁用/删除目标会导致“无启用管理员”时返回 CodeConflict。
// 仅当目标本身是启用管理员且系统启用管理员数 <= 1 时触发。
func (s *service) guardLastAdmin(ctx context.Context, target uuid.UUID) error {
t, err := s.repo.GetUserByID(ctx, target)
if err != nil {
return err
}
if !t.IsAdmin || !t.Enabled {
return nil
}
n, err := s.repo.CountAdmins(ctx)
if err != nil {
return err
}
if n <= 1 {
return errs.New(errs.CodeConflict, "必须保留至少一个启用的管理员")
}
return nil
}
// record 是审计的薄封装:recorder 为 nil 时跳过;用 WithoutCancel 派生 ctx 保证
// 响应返回后审计仍能落库。
func (s *service) record(ctx context.Context, actor *uuid.UUID, action, targetID string) {
if s.audit == nil {
return
}
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
UserID: actor,
Action: action,
TargetType: "user",
TargetID: targetID,
})
}
// randomPassword 生成一段强随机 base64 临时密码,用于管理员重置场景。
func randomPassword() (string, error) {
tok, _, err := NewSessionToken()
if err != nil {
return "", err
}
if len(tok) > tempPasswordLen {
tok = tok[:tempPasswordLen]
}
return tok, nil
}
+212 -1
View File
@@ -48,7 +48,97 @@ func (r *fakeRepo) GetUserByID(_ context.Context, id uuid.UUID) (*User, error) {
}
func (r *fakeRepo) CountUsers(_ context.Context) (int, error) { return len(r.users), nil }
func (r *fakeRepo) ListAdmins(_ context.Context) ([]*User, error) { return nil, nil }
func (r *fakeRepo) ListAdmins(_ context.Context) ([]*User, error) {
out := make([]*User, 0)
for _, u := range r.users {
if u.IsAdmin {
out = append(out, u)
}
}
return out, nil
}
func (r *fakeRepo) CountAdmins(_ context.Context) (int, error) {
n := 0
for _, u := range r.users {
if u.IsAdmin && u.Enabled {
n++
}
}
return n, nil
}
func (r *fakeRepo) ListUsers(_ context.Context) ([]*User, error) {
out := make([]*User, 0, len(r.users))
for _, u := range r.users {
out = append(out, u)
}
return out, nil
}
func (r *fakeRepo) findByID(id uuid.UUID) (*User, bool) {
for _, u := range r.users {
if u.ID == id {
return u, true
}
}
return nil, false
}
func (r *fakeRepo) UpdateProfile(_ context.Context, id uuid.UUID, displayName string) (*User, error) {
u, ok := r.findByID(id)
if !ok {
return nil, errs.New(errs.CodeNotFound, "no user")
}
u.DisplayName = displayName
return u, nil
}
func (r *fakeRepo) SetAdmin(_ context.Context, id uuid.UUID, isAdmin bool) (*User, error) {
u, ok := r.findByID(id)
if !ok {
return nil, errs.New(errs.CodeNotFound, "no user")
}
u.IsAdmin = isAdmin
return u, nil
}
func (r *fakeRepo) SetEnabled(_ context.Context, id uuid.UUID, enabled bool) (*User, error) {
u, ok := r.findByID(id)
if !ok {
return nil, errs.New(errs.CodeNotFound, "no user")
}
u.Enabled = enabled
return u, nil
}
func (r *fakeRepo) UpdatePassword(_ context.Context, id uuid.UUID, passwordHash string) error {
u, ok := r.findByID(id)
if !ok {
return errs.New(errs.CodeNotFound, "no user")
}
u.PasswordHash = passwordHash
return nil
}
func (r *fakeRepo) DeleteUser(_ context.Context, id uuid.UUID) error {
for email, u := range r.users {
if u.ID == id {
delete(r.users, email)
return nil
}
}
return nil
}
func (r *fakeRepo) DeleteSessionsByUserID(_ context.Context, userID uuid.UUID) error {
for k, s := range r.sessions {
if s.UserID == userID {
delete(r.sessions, k)
}
}
return nil
}
func (r *fakeRepo) CreateSession(_ context.Context, s *Session) error {
r.sessions[string(s.TokenHash)] = s
@@ -168,3 +258,124 @@ func TestLogout_RecordsAudit(t *testing.T) {
require.Len(t, spy.entries, 1)
require.Equal(t, "user.logout", spy.entries[0].Action)
}
// ===== 管理员用户管理测试 =====
// newAdminRepo 构造一个含一个启用管理员的 fakeRepo + service。
func newAdminRepo(t *testing.T) (*service, *fakeRepo, *User) {
t.Helper()
repo := newFakeRepo()
hash, err := HashPassword("password123")
require.NoError(t, err)
admin := &User{ID: uuid.New(), Email: "admin@b.c", DisplayName: "Admin", PasswordHash: hash, IsAdmin: true, Enabled: true}
repo.users[admin.Email] = admin
svc := NewService(repo, nil).(*service)
return svc, repo, admin
}
func TestCreateUser_AndListUsers(t *testing.T) {
svc, _, _ := newAdminRepo(t)
u, err := svc.CreateUser(context.Background(), CreateUserInput{
Email: "new@b.c", DisplayName: "New", Password: "password123", IsAdmin: false,
})
require.NoError(t, err)
require.Equal(t, "new@b.c", u.Email)
users, err := svc.ListUsers(context.Background())
require.NoError(t, err)
require.Len(t, users, 2)
}
func TestCreateUser_ShortPassword(t *testing.T) {
svc, _, _ := newAdminRepo(t)
_, err := svc.CreateUser(context.Background(), CreateUserInput{
Email: "new@b.c", DisplayName: "New", Password: "short",
})
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeInvalidInput, ae.Code)
}
func TestSetAdmin_CannotDemoteSelf(t *testing.T) {
svc, _, admin := newAdminRepo(t)
_, err := svc.SetAdmin(context.Background(), admin.ID, admin.ID, false)
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeConflict, ae.Code)
}
func TestSetAdmin_CannotDemoteLastAdmin(t *testing.T) {
svc, repo, admin := newAdminRepo(t)
// 加一个普通用户作 actor,避免触发自锁分支
actor := &User{ID: uuid.New(), Email: "actor@b.c", DisplayName: "A", IsAdmin: true, Enabled: true}
repo.users[actor.Email] = actor
// 现在有 2 个 admin;降级其中一个应成功
_, err := svc.SetAdmin(context.Background(), actor.ID, admin.ID, false)
require.NoError(t, err)
// 只剩 actor 一个 admin;再降级 actor(由自己以外触发不可能,构造另一普通用户)
plain := &User{ID: uuid.New(), Email: "p@b.c", DisplayName: "P", IsAdmin: false, Enabled: true}
repo.users[plain.Email] = plain
_, err = svc.SetAdmin(context.Background(), plain.ID, actor.ID, false)
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeConflict, ae.Code)
}
func TestSetEnabled_DisableRevokesSessions(t *testing.T) {
svc, repo, _ := newAdminRepo(t)
target := &User{ID: uuid.New(), Email: "t@b.c", DisplayName: "T", IsAdmin: false, Enabled: true}
repo.users[target.Email] = target
repo.sessions["s1"] = &Session{UserID: target.ID, TokenHash: []byte("s1")}
actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: true, Enabled: true}
repo.users[actor.Email] = actor
_, err := svc.SetEnabled(context.Background(), actor.ID, target.ID, false)
require.NoError(t, err)
require.False(t, repo.users["t@b.c"].Enabled)
require.Empty(t, repo.sessions)
}
func TestChangePassword_WrongOld(t *testing.T) {
svc, _, admin := newAdminRepo(t)
err := svc.ChangePassword(context.Background(), admin.ID, "wrong-old", "newpassword123")
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeUnauthorized, ae.Code)
}
func TestChangePassword_Success(t *testing.T) {
svc, repo, admin := newAdminRepo(t)
repo.sessions["s1"] = &Session{UserID: admin.ID, TokenHash: []byte("s1")}
err := svc.ChangePassword(context.Background(), admin.ID, "password123", "newpassword123")
require.NoError(t, err)
ok, err := VerifyPassword("newpassword123", repo.users["admin@b.c"].PasswordHash)
require.NoError(t, err)
require.True(t, ok)
require.Empty(t, repo.sessions) // 改密撤销全部会话
}
func TestAdminResetPassword_ReturnsTemp(t *testing.T) {
svc, repo, admin := newAdminRepo(t)
temp, err := svc.AdminResetPassword(context.Background(), admin.ID)
require.NoError(t, err)
require.GreaterOrEqual(t, len(temp), minPasswordLen)
ok, err := VerifyPassword(temp, repo.users["admin@b.c"].PasswordHash)
require.NoError(t, err)
require.True(t, ok)
}
func TestDeleteUser_CannotDeleteSelf(t *testing.T) {
svc, _, admin := newAdminRepo(t)
err := svc.DeleteUser(context.Background(), admin.ID, admin.ID)
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeConflict, ae.Code)
}
func TestDeleteUser_CannotDeleteLastAdmin(t *testing.T) {
svc, repo, admin := newAdminRepo(t)
actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: false, Enabled: true}
repo.users[actor.Email] = actor
err := svc.DeleteUser(context.Background(), actor.ID, admin.ID)
ae, ok := errs.As(err)
require.True(t, ok)
require.Equal(t, errs.CodeConflict, ae.Code)
}
+27 -11
View File
@@ -11,17 +11,18 @@ import (
)
type AcpAgentKind struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
BinaryPath string `json:"binary_path"`
Args []string `json:"args"`
EncryptedEnv []byte `json:"encrypted_env"`
Enabled bool `json:"enabled"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ToolAllowlist []string `json:"tool_allowlist"`
}
type AcpEvent struct {
@@ -36,6 +37,20 @@ type AcpEvent struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpPermissionRequest struct {
ID pgtype.UUID `json:"id"`
SessionID pgtype.UUID `json:"session_id"`
AgentRequestID string `json:"agent_request_id"`
ToolName string `json:"tool_name"`
ToolCall []byte `json:"tool_call"`
Options []byte `json:"options"`
Status string `json:"status"`
ChosenOptionID *string `json:"chosen_option_id"`
DecidedBy pgtype.UUID `json:"decided_by"`
DecidedAt pgtype.Timestamptz `json:"decided_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AcpSession struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
@@ -269,6 +284,7 @@ type User struct {
IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Enabled bool `json:"enabled"`
}
type UserSession struct {
+178 -8
View File
@@ -11,6 +11,17 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countAdmins = `-- name: CountAdmins :one
SELECT COUNT(*)::int FROM users WHERE is_admin = true AND enabled = true
`
func (q *Queries) CountAdmins(ctx context.Context) (int32, error) {
row := q.db.QueryRow(ctx, countAdmins)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const countUsers = `-- name: CountUsers :one
SELECT COUNT(*)::int FROM users
`
@@ -47,7 +58,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er
const createUser = `-- name: CreateUser :one
INSERT INTO users (id, email, password_hash, display_name, is_admin)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
`
type CreateUserParams struct {
@@ -75,6 +86,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
)
return i, err
}
@@ -97,11 +109,31 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte
return err
}
const deleteSessionsByUserID = `-- name: DeleteSessionsByUserID :exec
DELETE FROM user_sessions WHERE user_id = $1
`
func (q *Queries) DeleteSessionsByUserID(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteSessionsByUserID, userID)
return err
}
const deleteUser = `-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1
`
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteUser, id)
return err
}
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at
FROM user_sessions
WHERE token_hash = $1
AND expires_at > now()
SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at
FROM user_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
AND s.expires_at > now()
AND u.enabled = true
`
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (UserSession, error) {
@@ -119,7 +151,7 @@ func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
WHERE email = $1
`
@@ -135,12 +167,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
WHERE id = $1
`
@@ -156,12 +189,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
)
return i, err
}
const listAdmins = `-- name: ListAdmins :many
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
WHERE is_admin = true
ORDER BY id
@@ -184,6 +218,7 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
); err != nil {
return nil, err
}
@@ -195,6 +230,97 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
return items, nil
}
const listUsers = `-- name: ListUsers :many
SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
FROM users
ORDER BY created_at ASC
`
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
rows, err := q.db.Query(ctx, listUsers)
if err != nil {
return nil, err
}
defer rows.Close()
var items []User
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.DisplayName,
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setUserAdmin = `-- name: SetUserAdmin :one
UPDATE users
SET is_admin = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
`
type SetUserAdminParams struct {
ID pgtype.UUID `json:"id"`
IsAdmin bool `json:"is_admin"`
}
func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (User, error) {
row := q.db.QueryRow(ctx, setUserAdmin, arg.ID, arg.IsAdmin)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.DisplayName,
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
)
return i, err
}
const setUserEnabled = `-- name: SetUserEnabled :one
UPDATE users
SET enabled = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
`
type SetUserEnabledParams struct {
ID pgtype.UUID `json:"id"`
Enabled bool `json:"enabled"`
}
func (q *Queries) SetUserEnabled(ctx context.Context, arg SetUserEnabledParams) (User, error) {
row := q.db.QueryRow(ctx, setUserEnabled, arg.ID, arg.Enabled)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.DisplayName,
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
)
return i, err
}
const touchSession = `-- name: TouchSession :exec
UPDATE user_sessions
SET last_seen_at = now()
@@ -205,3 +331,47 @@ func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error {
_, err := q.db.Exec(ctx, touchSession, tokenHash)
return err
}
const updateUserPassword = `-- name: UpdateUserPassword :exec
UPDATE users
SET password_hash = $2, updated_at = now()
WHERE id = $1
`
type UpdateUserPasswordParams struct {
ID pgtype.UUID `json:"id"`
PasswordHash string `json:"password_hash"`
}
func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error {
_, err := q.db.Exec(ctx, updateUserPassword, arg.ID, arg.PasswordHash)
return err
}
const updateUserProfile = `-- name: UpdateUserProfile :one
UPDATE users
SET display_name = $2, updated_at = now()
WHERE id = $1
RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled
`
type UpdateUserProfileParams struct {
ID pgtype.UUID `json:"id"`
DisplayName string `json:"display_name"`
}
func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) {
row := q.db.QueryRow(ctx, updateUserProfile, arg.ID, arg.DisplayName)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.DisplayName,
&i.IsAdmin,
&i.CreatedAt,
&i.UpdatedAt,
&i.Enabled,
)
return i, err
}