feat(M3): 申请审批 + 邮件队列 — 公开申请、管理员审批自动建号、mail_logs 落库重试
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user