package service import ( "context" "errors" "fmt" "log/slog" "strings" "time" "gorm.io/gorm" "ws_usernode/internal/config" "ws_usernode/internal/mail" "ws_usernode/internal/model" "ws_usernode/internal/pkg" ) // 审批服务错误。 var ( ErrApprovalNotFound = errors.New("service: 申请单不存在") ErrApprovalReviewed = errors.New("service: 申请单已审批,不能重复操作") ErrUsernameTaken = errors.New("service: 用户名已被占用或已有待审批申请") ErrReasonRequired = errors.New("service: 拒绝申请必须填写理由") ) // ApprovalService 新账号申请与审批(PLAN F4):公开提交申请单;管理员审批 // 通过 → 自动创建账号(复用 UserService.Create 的建号与回滚逻辑)并邮件通知, // 拒绝 → 记录理由并邮件通知;被拒后可重新提交(重复检查仅针对待审批单)。 type ApprovalService struct { db *gorm.DB cfg *config.Config users *UserService mailer mail.Mailer log *slog.Logger } // NewApprovalService 创建审批服务。 func NewApprovalService(db *gorm.DB, cfg *config.Config, users *UserService, mailer mail.Mailer, log *slog.Logger) *ApprovalService { return &ApprovalService{db: db, cfg: cfg, users: users, mailer: mailer, log: log} } // Submit 提交新账号申请(公开接口,无需登录)。校验用户名/邮箱格式, // 并拒绝已被占用(系统已存在用户)或已有待审批申请的同名申请。 func (s *ApprovalService) Submit(ctx context.Context, username, email, supervisor, purpose string) (*model.Approval, error) { username = strings.TrimSpace(username) if err := pkg.ValidateUserName(username); err != nil { return nil, err } if err := pkg.ValidateEmail(email); err != nil { return nil, err } if err := s.usernameAvailable(ctx, username); err != nil { return nil, err } a := &model.Approval{ UsernameRequested: username, Email: strings.TrimSpace(email), Supervisor: strings.TrimSpace(supervisor), Purpose: strings.TrimSpace(purpose), Status: model.StatusPending, } if err := s.db.WithContext(ctx).Create(a).Error; err != nil { return nil, err } return a, nil } // List 申请单列表(admin),可按状态筛选,最新在前。 func (s *ApprovalService) List(ctx context.Context, status string) ([]model.Approval, error) { q := s.db.WithContext(ctx).Model(&model.Approval{}) if status != "" { q = q.Where("status = ?", status) } var rows []model.Approval if err := q.Order("id DESC").Find(&rows).Error; err != nil { return nil, err } return rows, nil } // Review 审批申请单。通过 → 自动创建账号(默认 TTL)并邮件通知申请人; // 拒绝 → 记录理由并邮件通知。已审批的单不可重复操作。 func (s *ApprovalService) Review(ctx context.Context, id uint, approve bool, reviewerID uint, reason string) (*model.Approval, error) { var a model.Approval if err := s.db.WithContext(ctx).First(&a, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrApprovalNotFound } return nil, err } if a.Status != model.StatusPending { return nil, ErrApprovalReviewed } now := time.Now() if approve { // 通过前再次校验用户名:审批期间可能已被管理员手动创建(只查用户表, // 不查待审批单——同名待审批在提交时已拦截,当前单本身即该用户名的待审批单)。 var n int64 if err := s.db.WithContext(ctx).Model(&model.User{}). Where("username = ?", s.cfg.System.UserPrefix+a.UsernameRequested).Count(&n).Error; err != nil { return nil, err } if n > 0 { return nil, ErrUsernameTaken } u, err := s.users.Create(ctx, a.UsernameRequested, a.Email, a.Supervisor, a.Purpose, 0, reviewerID) if err != nil { return nil, err } if err := s.db.WithContext(ctx).Model(&model.Approval{}).Where("id = ?", id).Updates(map[string]any{ "status": model.StatusApproved, "reviewer_id": reviewerID, "reviewed_at": &now, }).Error; err != nil { return nil, err } a.Status = model.StatusApproved a.ReviewerID = &reviewerID a.ReviewedAt = &now s.notifyApproved(ctx, &a, u) return &a, nil } reason = strings.TrimSpace(reason) if reason == "" { return nil, ErrReasonRequired } if err := s.db.WithContext(ctx).Model(&model.Approval{}).Where("id = ?", id).Updates(map[string]any{ "status": model.StatusRejected, "reviewer_id": reviewerID, "reviewed_at": &now, "reason": reason, }).Error; err != nil { return nil, err } a.Status = model.StatusRejected a.ReviewerID = &reviewerID a.ReviewedAt = &now a.Reason = reason s.notifyRejected(ctx, &a) return &a, nil } // usernameAvailable 校验用户名未被现有用户占用且无同名待审批申请。 func (s *ApprovalService) usernameAvailable(ctx context.Context, username string) error { full := s.cfg.System.UserPrefix + username var n int64 if err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", full).Count(&n).Error; err != nil { return err } if n > 0 { return ErrUsernameTaken } if err := s.db.WithContext(ctx).Model(&model.Approval{}). Where("username_requested = ? AND status = ?", username, model.StatusPending).Count(&n).Error; err != nil { return err } if n > 0 { return ErrUsernameTaken } return nil } // notifyApproved 通知申请人账号已创建(审批结果 + 账号创建通知,PLAN F5)。 // 邮件失败仅记日志,不阻断审批流程(经 QueuedMailer 时已落 mail_logs 可重试)。 func (s *ApprovalService) notifyApproved(ctx context.Context, a *model.Approval, u *model.User) { expire := "长期" if u.ExpireAt != nil { expire = u.ExpireAt.Format("2006-01-02 15:04 MST") } body := fmt.Sprintf(`您的服务器账号申请已通过审批,账号已创建。 账号信息: 用户名:%s 有效期至:%s 挂靠老师:%s 用途:%s 使用说明: 1. 系统账号口令已锁定,仅支持 SSH 公钥登录; 2. 在 %s 使用图形验证码 + 邮件验证码登录后,进入"我的密钥"上传您的 SSH 公钥; 3. 公钥同步后即可通过 SSH 登录服务器。 `, u.Username, expire, a.Supervisor, a.Purpose, s.cfg.App.BaseURL) if err := s.mailer.Send(ctx, a.Email, "服务器账号申请通过", body); err != nil { s.log.Warn("approval: 通过邮件发送失败", "approval_id", a.ID, "err", err) } } // notifyRejected 通知申请人申请被拒及理由(PLAN F4:拒绝后可重新提交)。 func (s *ApprovalService) notifyRejected(ctx context.Context, a *model.Approval) { body := fmt.Sprintf(`您的服务器账号申请未通过审批。 申请信息: 用户名:%s 邮箱:%s 挂靠老师:%s 用途:%s 拒绝理由:%s 如需重新申请,可在修正信息后再次提交。 `, a.UsernameRequested, a.Email, a.Supervisor, a.Purpose, a.Reason) if err := s.mailer.Send(ctx, a.Email, "服务器账号申请未通过", body); err != nil { s.log.Warn("approval: 拒绝邮件发送失败", "approval_id", a.ID, "err", err) } }