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