diff --git a/Makefile b/Makefile index 71d008d..b70ce30 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ NET_HOST := --network=host GO ?= go PODMAN ?= podman BIN := bin/usernode -VERSION ?= 0.3.0-m2 +VERSION ?= 0.3.0-m3 LDFLAGS := -s -w -X main.version=$(VERSION) GOFLAGS := -trimpath diff --git a/cmd/usernode/app.go b/cmd/usernode/app.go index eab1db9..a3ed6e2 100644 --- a/cmd/usernode/app.go +++ b/cmd/usernode/app.go @@ -94,8 +94,10 @@ func cmdServe(args []string) error { sessions := auth.NewDBSessionStore(db) resets := auth.NewDBResetTokenStore(db) limiter := auth.NewRateLimiter(cfg.Auth.MaxLoginFailures, cfg.Auth.LockDuration) - mailer := mail.New(cfg.SMTP, log) + // M3:所有邮件经队列(mail_logs 落库 + 失败重试),OTP 双通道不受邮件故障影响 + mailer := mail.NewQueuedMailer(mail.New(cfg.SMTP, log), db, log) authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log) + approvalSvc := service.NewApprovalService(db, cfg, userSvc, mailer, log) // 启动前自动迁移(骨架阶段保证表结构就绪;M5 部署建议显式 migrate) if err := model.Migrate(db); err != nil { @@ -103,13 +105,13 @@ func cmdServe(args []string) error { } log.Info("db: migrated", "driver", cfg.Database.Driver, "dsn", cfg.Database.DSN) - // 定时任务(骨架:M1/M4 填充过期扫描/回收/审计归档) + // 定时任务(M3:邮件失败重试;M4 填充过期扫描/回收/审计归档) sched := robfigcron.New() - cron.New(db, log).Register(sched) + cron.New(db, mailer, log).Register(sched) sched.Start() defer sched.Stop() - h := api.New(cfg, authSvc, userSvc, keySvc, auditSvc) + h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc) r := router.New(cfg, h, sessions, log) srv := server.New(cfg.Server.Listen, r, log) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 4625e7f..45736d3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -107,8 +107,9 @@ func setupTestAppWithSys(t *testing.T, sys system.Manager) *testApp { mailer := &recordingMailer{} log := slog.New(slog.NewTextHandler(io.Discard, nil)) authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log) + approvalSvc := service.NewApprovalService(db, cfg, userSvc, mailer, log) - h := api.New(cfg, authSvc, userSvc, keySvc, auditSvc) + h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc) r := router.New(cfg, h, sessions, log) if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil { @@ -480,3 +481,98 @@ func testSSHPubKey(t *testing.T) string { } return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))) } + +func TestAPIApprovalFlow(t *testing.T) { + app := setupTestApp(t) + + // 公开提交申请(无需登录) + w := app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{ + "username": "zhaoliu", "email": "zl@example.com", "supervisor": "prof.wang", "purpose": "课程实验", + }) + if w.Code != http.StatusOK { + t.Fatalf("submit status = %d, body=%s", w.Code, w.Body.String()) + } + appr := decodeBody(t, w)["data"].(map[string]any) + apprID := uint(appr["id"].(float64)) + if appr["status"] != "pending" { + t.Fatalf("approval status = %v", appr["status"]) + } + + // 同名待审批申请 → 409 + w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{ + "username": "zhaoliu", "email": "zl2@example.com", + }) + if w.Code != http.StatusConflict { + t.Fatalf("duplicate submit status = %d, want 409", w.Code) + } + + // 未登录访问列表 → 401;外部用户访问 → 403 + w = app.doJSON(http.MethodGet, "/api/v1/approvals", nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("unauth list status = %d, want 401", w.Code) + } + + // 管理员登录 → 列表 1 条 + w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"}) + ck := sessionCookie(t, w) + w = app.doJSON(http.MethodGet, "/api/v1/approvals?status=pending", nil, ck) + if w.Code != http.StatusOK { + t.Fatalf("list status = %d, body=%s", w.Code, w.Body.String()) + } + if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 { + t.Fatalf("pending items = %d, want 1", len(items)) + } + + // 拒绝必须填理由 → 400 + w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false}, ck) + if w.Code != http.StatusBadRequest { + t.Fatalf("reject without reason status = %d, want 400", w.Code) + } + + // 通过 → 自动建号 + 通知邮件 + w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": true}, ck) + if w.Code != http.StatusOK { + t.Fatalf("approve status = %d, body=%s", w.Code, w.Body.String()) + } + if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "approved" { + t.Fatalf("approved status = %v", status) + } + if app.mailer.lastTo != "zl@example.com" || !strings.Contains(app.mailer.lastBody, "ext_zhaoliu") { + t.Fatalf("approval mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody) + } + var u model.User + if err := app.db.First(&u, "username = ?", "ext_zhaoliu").Error; err != nil { + t.Fatalf("user not created after approve: %v", err) + } + if u.Status != model.UserStatusActive { + t.Fatalf("created user status = %s", u.Status) + } + + // 重复审批 → 409 + w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false, "reason": "x"}, ck) + if w.Code != http.StatusConflict { + t.Fatalf("re-review status = %d, want 409", w.Code) + } + + // 新申请 → 拒绝 + 理由 → 通知;被拒后可重新提交 + w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq@example.com"}) + if w.Code != http.StatusOK { + t.Fatalf("second submit status = %d", w.Code) + } + apprID2 := uint(decodeBody(t, w)["data"].(map[string]any)["id"].(float64)) + w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID2)+"/review", map[string]any{"approve": false, "reason": "用途不明确"}, ck) + if w.Code != http.StatusOK { + t.Fatalf("reject status = %d, body=%s", w.Code, w.Body.String()) + } + if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "rejected" { + t.Fatalf("rejected status = %v", status) + } + if app.mailer.lastTo != "qq@example.com" || !strings.Contains(app.mailer.lastBody, "用途不明确") { + t.Fatalf("reject mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody) + } + // 被拒后可重新提交同名申请 + w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq2@example.com"}) + if w.Code != http.StatusOK { + t.Fatalf("resubmit status = %d, body=%s", w.Code, w.Body.String()) + } +} diff --git a/internal/api/approval.go b/internal/api/approval.go new file mode 100644 index 0000000..17448b2 --- /dev/null +++ b/internal/api/approval.go @@ -0,0 +1,104 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "ws_usernode/internal/config" + "ws_usernode/internal/model" + "ws_usernode/internal/service" +) + +// ApprovalHandler 新账号申请与审批接口(PLAN F4): +// 公开提交(POST /approvals)、管理员列表(GET /approvals)、审批(POST /approvals/:id/review)。 +type ApprovalHandler struct { + svc *service.ApprovalService + cfg *config.Config + h *Handler // 访问审计 helper +} + +// ApprovalSubmitRequest 提交申请(用户名不含 ext_ 前缀)。 +type ApprovalSubmitRequest struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required"` + Supervisor string `json:"supervisor"` + Purpose string `json:"purpose"` +} + +// Submit POST /approvals —— 公开提交新账号申请。 +func (h *ApprovalHandler) Submit(c *gin.Context) { + var req ApprovalSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error()) + return + } + a, err := h.svc.Submit(c.Request.Context(), req.Username, req.Email, req.Supervisor, req.Purpose) + if err != nil { + h.h.audit(c, "approval.submit", "approval", "", map[string]any{"username": req.Username, "err": err.Error()}, model.ResultFailed) + switch { + case errors.Is(err, service.ErrUsernameTaken): + fail(c, http.StatusConflict, err.Error()) + default: + fail(c, http.StatusBadRequest, err.Error()) + } + return + } + h.h.audit(c, "approval.submit", "approval", strconv.FormatUint(uint64(a.ID), 10), map[string]any{"username": a.UsernameRequested}, model.ResultSuccess) + ok(c, a) +} + +// List GET /approvals —— 申请单列表(admin),按状态筛选。 +func (h *ApprovalHandler) List(c *gin.Context) { + rows, err := h.svc.List(c.Request.Context(), c.Query("status")) + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + ok(c, gin.H{"items": rows}) +} + +// ApprovalReviewRequest 审批请求:通过 → approve=true;拒绝 → approve=false + 理由。 +type ApprovalReviewRequest struct { + Approve bool `json:"approve"` + Reason string `json:"reason"` +} + +// Review POST /approvals/:id/review —— 审批(通过 → 自动建号 / 拒绝 + 理由)。 +func (h *ApprovalHandler) Review(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的申请单 ID") + return + } + var req ApprovalReviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, "请求参数不合法: "+err.Error()) + return + } + reviewerID := uint(0) + if sess := sessionFrom(c); sess != nil { + reviewerID = sess.RefID + } + a, err := h.svc.Review(c.Request.Context(), uint(id), req.Approve, reviewerID, req.Reason) + if err != nil { + h.h.audit(c, "approval.review", "approval", c.Param("id"), map[string]any{"approve": req.Approve, "err": err.Error()}, model.ResultFailed) + switch { + case errors.Is(err, service.ErrApprovalNotFound): + fail(c, http.StatusNotFound, err.Error()) + case errors.Is(err, service.ErrApprovalReviewed): + fail(c, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrReasonRequired): + fail(c, http.StatusBadRequest, err.Error()) + case errors.Is(err, service.ErrUsernameTaken): + fail(c, http.StatusConflict, err.Error()) + default: + fail(c, http.StatusInternalServerError, err.Error()) + } + return + } + h.h.audit(c, "approval.review", "approval", c.Param("id"), map[string]any{"approve": req.Approve, "reason": req.Reason, "status": a.Status}, model.ResultSuccess) + ok(c, a) +} diff --git a/internal/api/handler.go b/internal/api/handler.go index 818bfa5..d510de0 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -1,6 +1,7 @@ // Package api 为 HTTP handler 层(RESTful v1)。 // M1 覆盖认证(管理员/外部用户登录、会话、OTP)与用户管理 CRUD; -// M2 覆盖 SSH 公钥管理(上传/重命名/吊销/列表)。 +// M2 覆盖 SSH 公钥管理(上传/重命名/吊销/列表); +// M3 覆盖申请审批(公开提交 + 管理员审批 + 邮件通知)。 package api import ( @@ -18,17 +19,18 @@ import ( // Handler 聚合各模块 handler,作为路由注册的挂载点。 type Handler struct { - Health *HealthHandler - Auth *AuthHandler - User *UserHandler - Key *KeyHandler + Health *HealthHandler + Auth *AuthHandler + User *UserHandler + Key *KeyHandler + Approval *ApprovalHandler authSvc *service.AuthService auditSvc *service.AuditService } // New 创建 handler 集合。 -func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, keySvc *service.KeyService, auditSvc *service.AuditService) *Handler { +func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.UserService, keySvc *service.KeyService, approvalSvc *service.ApprovalService, auditSvc *service.AuditService) *Handler { h := &Handler{ Health: &HealthHandler{startedAt: time.Now()}, authSvc: authSvc, @@ -37,6 +39,7 @@ func New(cfg *config.Config, authSvc *service.AuthService, userSvc *service.User h.Auth = &AuthHandler{svc: authSvc, cfg: cfg} h.User = &UserHandler{svc: userSvc, cfg: cfg, h: h} h.Key = &KeyHandler{svc: keySvc, cfg: cfg, h: h} + h.Approval = &ApprovalHandler{svc: approvalSvc, cfg: cfg, h: h} return h } @@ -63,7 +66,7 @@ type HealthHandler struct { func (h *HealthHandler) Healthz(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", - "version": "0.3.0-m2", + "version": "0.3.0-m3", "uptime": time.Since(h.startedAt).String(), "go": runtime.Version(), "timestamp": time.Now().UTC().Format(time.RFC3339), diff --git a/internal/cron/cron.go b/internal/cron/cron.go index e82c1d6..41b33d8 100644 --- a/internal/cron/cron.go +++ b/internal/cron/cron.go @@ -1,27 +1,39 @@ -// Package cron 提供定时任务:过期扫描、回收、审计归档。 -// M0 注册任务框架(任务函数在 M1/M4 接入),单实例部署说明见 PLAN §11。 +// Package cron 提供定时任务:邮件重试、过期扫描、回收、审计归档。 +// M0 注册任务框架(过期扫描/回收在 M1/M4 接入);M3 接入邮件失败重试; +// 单实例部署说明见 PLAN §11。 package cron import ( + "context" "log/slog" "github.com/robfig/cron/v3" "gorm.io/gorm" + + "ws_usernode/internal/mail" ) // Jobs 聚合定时任务依赖与注册。 type Jobs struct { - db *gorm.DB - log *slog.Logger + db *gorm.DB + mailer mail.Retryable + log *slog.Logger } -// New 创建定时任务集合。 -func New(db *gorm.DB, log *slog.Logger) *Jobs { - return &Jobs{db: db, log: log} +// New 创建定时任务集合。mailer 为邮件队列(失败重试,PLAN F5)。 +func New(db *gorm.DB, mailer mail.Retryable, log *slog.Logger) *Jobs { + return &Jobs{db: db, mailer: mailer, log: log} } -// Register 将任务注册到 cron。骨架阶段注册空任务,M1/M4 填充实现。 +// Register 将任务注册到 cron。 func (j *Jobs) Register(c *cron.Cron) { + // 邮件失败重试(M3):每 5 分钟重试失败邮件(超出 mail.MaxRetries 停止) + c.AddFunc("@every 5m", func() { + if err := j.mailer.Retry(context.Background(), mail.MaxRetries); err != nil { + j.log.Error("cron: mail retry failed", "err", err) + } + }) + // 过期扫描(M4):每日扫描到期账号,锁系统账号 + 邮件通知 // 回收任务(M4):超回收期账号自动回收(userdel + 保留审计) // 审计归档(M4):每日将过期审计记录导出归档后清理 diff --git a/internal/mail/mailer.go b/internal/mail/mailer.go index fd32c8e..b39d471 100644 --- a/internal/mail/mailer.go +++ b/internal/mail/mailer.go @@ -1,6 +1,6 @@ -// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS), -// 发送队列/重试/失败记录(mail_logs)在 M3 完善;SMTP 未配置时退化为 -// LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。 +// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS); +// M3 引入 QueuedMailer(mail_logs 队列 + 失败重试,见 queue.go), +// SMTP 未配置时退化为 LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。 package mail import ( diff --git a/internal/mail/queue.go b/internal/mail/queue.go new file mode 100644 index 0000000..6021430 --- /dev/null +++ b/internal/mail/queue.go @@ -0,0 +1,84 @@ +package mail + +import ( + "context" + "log/slog" + + "gorm.io/gorm" + + "ws_usernode/internal/model" +) + +// 邮件状态(mail_logs.status)。 +const ( + StatusPending = "pending" // 已入队,等待直发 + StatusSent = "sent" // 发送成功 + StatusFailed = "failed" // 发送失败,等待重试 +) + +// MaxRetries 单封邮件最大重试次数;超出后停止重试,保留失败记录(PLAN F5)。 +const MaxRetries = 5 + +// Retryable 可由 cron 定时调用的邮件队列重试接口。 +type Retryable interface { + Retry(ctx context.Context, limit int) error +} + +// QueuedMailer 邮件队列实现(PLAN F5):每封邮件先落 mail_logs 再同步尝试直发; +// 失败记录 error 与 retry_count,由 cron 定时重试;成功标记 sent。 +// Send 恒返回 nil(除 DB 落库失败):邮件失败不阻断业务 +// (OTP 双通道、审批结果等,见 AuthService.UserOTPSend 的既有约定)。 +type QueuedMailer struct { + base Mailer + db *gorm.DB + log *slog.Logger +} + +// NewQueuedMailer 创建邮件队列,包装底层直发实现(SMTP 或 LogMailer)。 +func NewQueuedMailer(base Mailer, db *gorm.DB, log *slog.Logger) *QueuedMailer { + return &QueuedMailer{base: base, db: db, log: log} +} + +// Send 入队并尝试直发。返回 nil 表示已受理;实际送达与否见 mail_logs。 +func (m *QueuedMailer) Send(ctx context.Context, to, subject, body string) error { + entry := model.MailLog{To: to, Subject: subject, Body: body, Status: StatusPending} + if err := m.db.WithContext(ctx).Create(&entry).Error; err != nil { + m.log.Error("mail: 记录 mail_log 失败", "err", err) + return err + } + if err := m.base.Send(ctx, to, subject, body); err != nil { + _ = m.db.WithContext(ctx).Model(&entry).Updates(map[string]any{ + "status": StatusFailed, "error": err.Error(), "retry_count": 1, + }).Error + m.log.Warn("mail: 直发失败,进入重试队列", "to", to, "subject", subject, "err", err) + return nil // 非阻断 + } + _ = m.db.WithContext(ctx).Model(&entry).Update("status", StatusSent).Error + return nil +} + +// Retry 重试失败邮件(retry_count < limit,按原内容重发)。成功标记 sent, +// 失败累加 retry_count 并刷新 error;达到上限后不再挑选,保留失败记录待人工处理。 +func (m *QueuedMailer) Retry(ctx context.Context, limit int) error { + if limit <= 0 { + return nil + } + var rows []model.MailLog + if err := m.db.WithContext(ctx). + Where("status = ? AND retry_count < ?", StatusFailed, limit). + Find(&rows).Error; err != nil { + return err + } + for _, r := range rows { + if err := m.base.Send(ctx, r.To, r.Subject, r.Body); err != nil { + _ = m.db.WithContext(ctx).Model(&model.MailLog{}).Where("id = ?", r.ID).Updates(map[string]any{ + "retry_count": r.RetryCount + 1, "error": err.Error(), + }).Error + m.log.Warn("mail: 重试仍失败", "id", r.ID, "to", r.To, "retry_count", r.RetryCount+1, "err", err) + continue + } + _ = m.db.WithContext(ctx).Model(&model.MailLog{}).Where("id = ?", r.ID).Update("status", StatusSent).Error + m.log.Info("mail: 重试成功", "id", r.ID, "to", r.To) + } + return nil +} diff --git a/internal/mail/queue_test.go b/internal/mail/queue_test.go new file mode 100644 index 0000000..191b67f --- /dev/null +++ b/internal/mail/queue_test.go @@ -0,0 +1,130 @@ +package mail + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "gorm.io/gorm" + + "ws_usernode/internal/model" +) + +// flakyMailer 前 n 次发送失败,之后成功;用于验证重试。 +type flakyMailer struct { + failures int + calls int +} + +var errFlaky = errors.New("mail: smtp down") + +func (m *flakyMailer) Send(_ context.Context, _, _, _ string) error { + m.calls++ + if m.calls <= m.failures { + return errFlaky + } + return nil +} + +// recordingMailer 记录直发调用次数。 +type recordingMailer struct{ calls int } + +func (m *recordingMailer) Send(context.Context, string, string, string) error { + m.calls++ + return nil +} + +func testQueued(t *testing.T, base Mailer) (*QueuedMailer, *gorm.DB) { + t.Helper() + db, err := model.Open("sqlite", ":memory:", false) + if err != nil { + t.Fatalf("open test db: %v", err) + } + if err := db.AutoMigrate(&model.MailLog{}); err != nil { + t.Fatalf("migrate mail_logs: %v", err) + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + return NewQueuedMailer(base, db, log), db +} + +func TestQueuedMailerSendSuccess(t *testing.T) { + rec := &recordingMailer{} + q, db := testQueued(t, rec) + if err := q.Send(context.Background(), "a@example.com", "主题", "正文"); err != nil { + t.Fatalf("send: %v", err) + } + if rec.calls != 1 { + t.Fatalf("base send calls = %d, want 1", rec.calls) + } + var n int64 + if err := db.Model(&model.MailLog{}).Where("status = ?", StatusSent).Count(&n).Error; err != nil { + t.Fatalf("count sent: %v", err) + } + if n != 1 { + t.Fatalf("sent = %d, want 1", n) + } +} + +func TestQueuedMailerFailureQueuedAndRetry(t *testing.T) { + base := &flakyMailer{failures: 2} + q, db := testQueued(t, base) + + // 第一次发送失败:Send 仍返回 nil(不阻断业务),落 failed 记录 + if err := q.Send(context.Background(), "b@example.com", "s", "body"); err != nil { + t.Fatalf("send returned error: %v", err) + } + if base.calls != 1 { + t.Fatalf("base calls = %d, want 1", base.calls) + } + var row model.MailLog + if err := db.First(&row, "status = ?", StatusFailed).Error; err != nil { + t.Fatalf("failed row: %v", err) + } + if row.RetryCount != 1 || row.Error != errFlaky.Error() { + t.Fatalf("failed row = %+v", row) + } + + // Retry 1:仍失败 → retry_count=2;Retry 2:成功 → sent + if err := q.Retry(context.Background(), 5); err != nil { + t.Fatalf("retry: %v", err) + } + if base.calls != 2 { + t.Fatalf("calls after retry1 = %d, want 2", base.calls) + } + if err := q.Retry(context.Background(), 5); err != nil { + t.Fatalf("retry2: %v", err) + } + if base.calls != 3 { + t.Fatalf("calls after retry2 = %d, want 3", base.calls) + } + var n int64 + if err := db.Model(&model.MailLog{}).Where("status = ?", StatusSent).Count(&n).Error; err != nil { + t.Fatalf("count sent: %v", err) + } + if n != 1 { + t.Fatalf("sent = %d, want 1", n) + } +} + +func TestQueuedMailerRetryHitsMax(t *testing.T) { + base := &flakyMailer{failures: 100} // 永远失败 + q, db := testQueued(t, base) + if err := q.Send(context.Background(), "c@example.com", "s", "body"); err != nil { + t.Fatalf("send: %v", err) + } + // 达到上限后 Retry 不再挑选(retry_count 不再增长) + for i := 0; i < 10; i++ { + if err := q.Retry(context.Background(), MaxRetries); err != nil { + t.Fatalf("retry: %v", err) + } + } + var row model.MailLog + if err := db.First(&row, "status = ?", StatusFailed).Error; err != nil { + t.Fatalf("failed row: %v", err) + } + if row.RetryCount > MaxRetries { + t.Fatalf("retry_count = %d, want <= %d", row.RetryCount, MaxRetries) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 250ebe5..79d5ab6 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -130,10 +130,12 @@ type Setting struct { } // MailLog 邮件发送记录(队列 + 重试 + 失败记录)。 +// Body 保存原文以便失败后按原内容重试(队列的载荷),仅用于重发,不对外展示。 type MailLog struct { ID uint `gorm:"primaryKey" json:"id"` To string `gorm:"size:255;index;not null" json:"to"` Subject string `gorm:"size:255" json:"subject"` + Body string `gorm:"type:text" json:"-"` Status string `gorm:"size:16;not null;default:pending;index" json:"status"` Error string `gorm:"type:text" json:"error"` RetryCount int `gorm:"not null;default:0" json:"retry_count"` diff --git a/internal/router/router.go b/internal/router/router.go index d7efdaa..c71a5a8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -66,10 +66,15 @@ func New(cfg *config.Config, h *api.Handler, sessions auth.SessionStore, log *sl me.DELETE("/keys/:id", h.Key.Revoke) } + // 申请审批(M3):提交公开;列表与审批需管理员会话 + v1.POST("/approvals", h.Approval.Submit) + approvals := v1.Group("/approvals", sessionMiddleware(sessions), requireUserType(auth.SessionUserAdmin)) + { + approvals.GET("", h.Approval.List) + approvals.POST("/:id/review", h.Approval.Review) + } + // 后续里程碑 - v1.POST("/approvals", notImplemented("提交申请(M3)")) - v1.GET("/approvals", notImplemented("申请列表(M3)")) - v1.POST("/approvals/:id/review", notImplemented("审批(M3)")) v1.GET("/audit", notImplemented("审计查询(M4)")) v1.GET("/audit/export", notImplemented("审计导出(M4)")) v1.GET("/settings", notImplemented("设置(M4)")) diff --git a/internal/service/approval.go b/internal/service/approval.go new file mode 100644 index 0000000..a954aae --- /dev/null +++ b/internal/service/approval.go @@ -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) + } +} diff --git a/internal/service/approval_test.go b/internal/service/approval_test.go new file mode 100644 index 0000000..c544079 --- /dev/null +++ b/internal/service/approval_test.go @@ -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) + } +}