feat(M3): 申请审批 + 邮件队列 — 公开申请、管理员审批自动建号、mail_logs 落库重试
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ws_usernode/internal/model"
|
||||
)
|
||||
|
||||
// recordingMail 捕获邮件发送,用于断言审批通知。
|
||||
type recordingMail struct {
|
||||
lastTo string
|
||||
lastSubject string
|
||||
lastBody string
|
||||
}
|
||||
|
||||
func (m *recordingMail) Send(_ context.Context, to, subject, body string) error {
|
||||
m.lastTo = to
|
||||
m.lastSubject = subject
|
||||
m.lastBody = body
|
||||
return nil
|
||||
}
|
||||
|
||||
func testApprovalSvc(t *testing.T) (*ApprovalService, *UserService, *recordingMail) {
|
||||
t.Helper()
|
||||
db := testDB(t)
|
||||
sys := newFakeSys()
|
||||
users := NewUserService(db, sys, testConfig())
|
||||
mailer := &recordingMail{}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewApprovalService(db, testConfig(), users, mailer, log), users, mailer
|
||||
}
|
||||
|
||||
func TestApprovalServiceSubmitValidate(t *testing.T) {
|
||||
svc, _, _ := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 非法用户名 / 邮箱
|
||||
if _, err := svc.Submit(ctx, "Bad Name", "x@example.com", "", ""); err == nil {
|
||||
t.Fatal("invalid username should fail")
|
||||
}
|
||||
if _, err := svc.Submit(ctx, "okname", "not-an-email", "", ""); err == nil {
|
||||
t.Fatal("invalid email should fail")
|
||||
}
|
||||
|
||||
// 合法提交
|
||||
a, err := svc.Submit(ctx, "zhangsan", "zs@example.com", "prof.li", "科研")
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
if a.Status != model.StatusPending || a.UsernameRequested != "zhangsan" {
|
||||
t.Fatalf("approval = %+v", a)
|
||||
}
|
||||
|
||||
// 同名待审批 → 冲突
|
||||
if _, err := svc.Submit(ctx, "zhangsan", "z2@example.com", "", ""); !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Fatalf("duplicate pending err = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalServiceReviewApproveCreatesUser(t *testing.T) {
|
||||
svc, users, mailer := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := svc.Submit(ctx, "lisi", "ls@example.com", "prof.wang", "毕设")
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
|
||||
// 拒绝必须填理由
|
||||
if _, err := svc.Review(ctx, a.ID, false, 1, " "); !errors.Is(err, ErrReasonRequired) {
|
||||
t.Fatalf("reject without reason err = %v, want ErrReasonRequired", err)
|
||||
}
|
||||
|
||||
// 通过 → 自动建号(系统账号 + DB 用户)+ 通知邮件
|
||||
got, err := svc.Review(ctx, a.ID, true, 1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("approve: %v", err)
|
||||
}
|
||||
if got.Status != model.StatusApproved || got.ReviewerID == nil || *got.ReviewerID != 1 {
|
||||
t.Fatalf("approved = %+v", got)
|
||||
}
|
||||
u, err := users.GetByUsername(ctx, "ext_lisi")
|
||||
if err != nil {
|
||||
t.Fatalf("user not created: %v", err)
|
||||
}
|
||||
if u.Status != model.UserStatusActive {
|
||||
t.Fatalf("user status = %s", u.Status)
|
||||
}
|
||||
if mailer.lastTo != "ls@example.com" || !strings.Contains(mailer.lastBody, "ext_lisi") {
|
||||
t.Fatalf("approval mail = %s / %s", mailer.lastTo, mailer.lastBody)
|
||||
}
|
||||
// 默认 TTL(90 天)生效
|
||||
if u.ExpireAt == nil || time.Until(*u.ExpireAt) < 80*24*time.Hour {
|
||||
t.Fatalf("expire_at = %v", u.ExpireAt)
|
||||
}
|
||||
|
||||
// 重复审批 → 冲突
|
||||
if _, err := svc.Review(ctx, a.ID, false, 1, "x"); !errors.Is(err, ErrApprovalReviewed) {
|
||||
t.Fatalf("re-review err = %v, want ErrApprovalReviewed", err)
|
||||
}
|
||||
// 用户名已被占用(已建号)→ 新申请冲突
|
||||
if _, err := svc.Submit(ctx, "lisi", "x@example.com", "", ""); !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Fatalf("resubmit taken err = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalServiceRejectAndResubmit(t *testing.T) {
|
||||
svc, _, mailer := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := svc.Submit(ctx, "wangwu", "ww@example.com", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("submit: %v", err)
|
||||
}
|
||||
got, err := svc.Review(ctx, a.ID, false, 1, "用途不明确")
|
||||
if err != nil {
|
||||
t.Fatalf("reject: %v", err)
|
||||
}
|
||||
if got.Status != model.StatusRejected || got.Reason != "用途不明确" {
|
||||
t.Fatalf("rejected = %+v", got)
|
||||
}
|
||||
if mailer.lastTo != "ww@example.com" || !strings.Contains(mailer.lastBody, "用途不明确") {
|
||||
t.Fatalf("reject mail = %s / %s", mailer.lastTo, mailer.lastBody)
|
||||
}
|
||||
|
||||
// 被拒后可重新提交同名申请
|
||||
if _, err := svc.Submit(ctx, "wangwu", "ww2@example.com", "", ""); err != nil {
|
||||
t.Fatalf("resubmit after reject: %v", err)
|
||||
}
|
||||
// 不存在 → ErrApprovalNotFound
|
||||
if _, err := svc.Review(ctx, 99999, true, 1, ""); !errors.Is(err, ErrApprovalNotFound) {
|
||||
t.Fatalf("missing review err = %v, want ErrApprovalNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalServiceList(t *testing.T) {
|
||||
svc, _, _ := testApprovalSvc(t)
|
||||
ctx := context.Background()
|
||||
for _, n := range []string{"a1", "a2", "a3"} {
|
||||
if _, err := svc.Submit(ctx, n, n+"@example.com", "", ""); err != nil {
|
||||
t.Fatalf("submit %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
rows, err := svc.List(ctx, model.StatusPending)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("pending count = %d, want 3", len(rows))
|
||||
}
|
||||
// 最新在前
|
||||
if rows[0].UsernameRequested != "a3" {
|
||||
t.Fatalf("first = %s, want a3", rows[0].UsernameRequested)
|
||||
}
|
||||
if all, err := svc.List(ctx, ""); err != nil || len(all) != 3 {
|
||||
t.Fatalf("list all = %d, %v", len(all), err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user