You've already forked agentic-coding-workflow
修改
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user