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
+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
}