package adminmgr import ( "context" "errors" "fmt" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminrole" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB redis *redis.Client } func NewRepository(db *gorm.DB, redis *redis.Client) *Repository { return &Repository{db: db, redis: redis} } func (r *Repository) List(ctx context.Context, page, pageSize int) (*PaginatedResult, error) { db := r.db.WithContext(ctx) var total int64 if err := db.Model(&model.AdminUser{}).Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var admins []model.AdminUser if err := db.Order("id DESC").Offset(offset).Limit(pageSize).Find(&admins).Error; err != nil { return nil, err } items := make([]AdminUserDTO, 0, len(admins)) for _, admin := range admins { roles, _ := r.getAdminRoles(ctx, admin.ID) items = append(items, AdminUserDTO{ ID: admin.ID, Username: admin.Username, Nickname: admin.Nickname, Status: admin.Status, Roles: roles, LastLoginAt: admin.LastLoginAt, CreatedAt: admin.CreatedAt, UpdatedAt: admin.UpdatedAt, }) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } func (r *Repository) FindByID(ctx context.Context, id uint64) (*AdminUserDTO, error) { var admin model.AdminUser if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { return nil, err } roles, _ := r.getAdminRoles(ctx, admin.ID) dto := AdminUserDTO{ ID: admin.ID, Username: admin.Username, Nickname: admin.Nickname, Status: admin.Status, Roles: roles, LastLoginAt: admin.LastLoginAt, CreatedAt: admin.CreatedAt, UpdatedAt: admin.UpdatedAt, } return &dto, nil } func (r *Repository) Create(ctx context.Context, req CreateAdminRequest) (*AdminUserDTO, error) { hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return nil, err } // 仅部署 bootstrap 首个超级管理员会强制改密;后台新建账号不强制。 admin := model.AdminUser{ Username: req.Username, PasswordHash: string(hash), Nickname: req.Nickname, Status: "active", TokenVersion: 1, PasswordMustChange: false, } if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil { return nil, err } return r.FindByID(ctx, admin.ID) } func (r *Repository) Update(ctx context.Context, id uint64, req UpdateAdminRequest) (*AdminUserDTO, error) { db := r.db.WithContext(ctx) var admin model.AdminUser if err := db.First(&admin, id).Error; err != nil { return nil, err } if req.Nickname != "" { admin.Nickname = req.Nickname } if req.Status != "" { admin.Status = req.Status } if err := db.Save(&admin).Error; err != nil { return nil, err } if req.Status != "" { if err := r.bumpTokenVersion(db, ctx, id, "admin_status_changed"); err != nil { return nil, err } r.invalidatePermCache(ctx, id) } return r.FindByID(ctx, id) } func (r *Repository) Delete(ctx context.Context, id uint64, currentAdminID uint64) error { if id == currentAdminID { return ErrCannotDeleteSelf } db := r.db.WithContext(ctx) var admin model.AdminUser if err := db.First(&admin, id).Error; err != nil { return err } // 检查是否是最后一个 super_admin isLastSuper, err := r.isLastSuperAdmin(ctx, id) if err != nil { return err } if isLastSuper { return ErrLastSuperAdmin } return db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("admin_user_id = ?", id).Delete(&model.AdminUserRole{}).Error; err != nil { return err } if err := tx.Delete(&model.AdminUser{}, id).Error; err != nil { return err } r.invalidatePermCache(ctx, id) return nil }) } func (r *Repository) AssignRoles(ctx context.Context, adminID uint64, roleIDs []uint64) error { db := r.db.WithContext(ctx) var admin model.AdminUser if err := db.First(&admin, adminID).Error; err != nil { return err } err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("admin_user_id = ?", adminID).Delete(&model.AdminUserRole{}).Error; err != nil { return err } for _, rid := range roleIDs { aur := model.AdminUserRole{AdminID: adminID, RoleID: rid} if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&aur).Error; err != nil { return err } } return r.bumpTokenVersion(tx, ctx, adminID, "roles_changed") }) if err != nil { return err } r.invalidatePermCache(ctx, adminID) return nil } func (r *Repository) ChangeOwnPassword(ctx context.Context, id uint64, oldPwd, newPwd string) error { var admin model.AdminUser if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { return err } if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(oldPwd)); err != nil { return ErrWrongPassword } hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost) if err != nil { return err } return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&admin).Updates(map[string]any{ "password_hash": string(hash), "password_must_change": false, }).Error; err != nil { return err } return r.bumpTokenVersion(tx, ctx, id, "password_changed") }) } func (r *Repository) ResetPassword(ctx context.Context, id uint64, newPwd string) error { var admin model.AdminUser if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil { return err } hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost) if err != nil { return err } // 管理员重置密码后直接可用,不再二次强制改密(强制改密仅用于 bootstrap 初始账号)。 return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&admin).Updates(map[string]any{ "password_hash": string(hash), "password_must_change": false, }).Error; err != nil { return err } return r.bumpTokenVersion(tx, ctx, id, "password_reset") }) } func (r *Repository) GetPermissionCodes(ctx context.Context, adminID uint64) ([]string, error) { var codes []string err := r.db.WithContext(ctx).Table("admin_user_roles aur"). Select("DISTINCT p.code"). Joins("JOIN role_permissions rp ON rp.role_id = aur.role_id"). Joins("JOIN permissions p ON p.id = rp.permission_id"). Where("aur.admin_user_id = ?", adminID). Pluck("code", &codes).Error return codes, err } func (r *Repository) getAdminRoles(ctx context.Context, adminID uint64) ([]adminrole.RoleDTO, error) { var roles []model.Role err := r.db.WithContext(ctx).Table("roles"). Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id"). Where("aur.admin_user_id = ?", adminID). Find(&roles).Error if err != nil { return nil, err } result := make([]adminrole.RoleDTO, 0, len(roles)) for _, role := range roles { result = append(result, adminrole.RoleDTO{ ID: role.ID, Code: role.Code, Name: role.Name, Description: role.Description, }) } return result, nil } func (r *Repository) isLastSuperAdmin(ctx context.Context, adminID uint64) (bool, error) { var superRole model.Role db := r.db.WithContext(ctx) if err := db.Where("code = ?", "super_admin").First(&superRole).Error; err != nil { return false, nil // 没有 super_admin 角色,不限制 } var count int64 err := db.Model(&model.AdminUserRole{}). Where("role_id = ? AND admin_user_id != ?", superRole.ID, adminID). Count(&count).Error return count == 0, err } func (r *Repository) invalidatePermCache(ctx context.Context, adminID uint64) { if r.redis == nil { return } r.redis.Del(ctx, permCacheKey(adminID)) } func (r *Repository) bumpTokenVersion(db *gorm.DB, ctx context.Context, adminID uint64, reason string) error { if err := db.Model(&model.AdminUser{}). Where("id = ?", adminID). UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil { return err } bizID := adminID return auditlog.Append(db, auditlog.Entry{ ActorType: "admin", ActorID: logging.AdminIDFromContext(ctx), Action: "auth.token_version.bump", BizType: "admin_user", BizID: &bizID, Meta: auditlog.Meta{RequestID: logging.RequestIDFromContext(ctx)}, Detail: map[string]any{ "target_admin_id": adminID, "reason": reason, }, }) } func permCacheKey(adminID uint64) string { return fmt.Sprintf("admin:perms:%d", adminID) } var ( ErrCannotDeleteSelf = errors.New("cannot delete self") ErrLastSuperAdmin = errors.New("cannot delete the last super admin") ErrWrongPassword = errors.New("wrong password") ) func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }