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
+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)
}
}