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