202 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|