feat(M3): 申请审批 + 邮件队列 — 公开申请、管理员审批自动建号、mail_logs 落库重试

This commit is contained in:
2026-08-30 10:01:38 +08:00
parent a5f501dba4
commit 1ea18490e0
13 changed files with 830 additions and 27 deletions
+97 -1
View File
@@ -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())
}
}
+104
View File
@@ -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)
}
+10 -7
View File
@@ -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),
+20 -8
View File
@@ -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):每日将过期审计记录导出归档后清理
+3 -3
View File
@@ -1,6 +1,6 @@
// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS
// 发送队列/重试/失败记录(mail_logs)在 M3 完善;SMTP 未配置时退化为
// LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。
// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS
// M3 引入 QueuedMailermail_logs 队列 + 失败重试,见 queue.go),
// SMTP 未配置时退化为 LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。
package mail
import (
+84
View File
@@ -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
}
+130
View File
@@ -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=2Retry 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)
}
}
+2
View File
@@ -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"`
+8 -3
View File
@@ -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"))
+201
View File
@@ -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)
}
}
+164
View File
@@ -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)
}
// 默认 TTL90 天)生效
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)
}
}