Files
usernode/internal/service/approval.go
T

202 lines
6.9 KiB
Go

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