From 50352d67caf1bb82b001019267e355a03a5fbfb7 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Thu, 16 Apr 2026 12:02:25 +0800 Subject: [PATCH] feat(supervisor): allow up to 3 supervisors per owner --- .../quitcheckin/handler/supervisor_handler.go | 2 + internal/quitcheckin/service/supervisor.go | 27 +++++++--- .../quitcheckin/service/supervisor_test.go | 54 ++++++++++++++++++- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/internal/quitcheckin/handler/supervisor_handler.go b/internal/quitcheckin/handler/supervisor_handler.go index 4676796..d2ee6fd 100644 --- a/internal/quitcheckin/handler/supervisor_handler.go +++ b/internal/quitcheckin/handler/supervisor_handler.go @@ -62,6 +62,8 @@ func (h *Handler) BindSupervisorInvite(c *gin.Context) { msg = "不能绑定自己" case "监督关系已存在": msg = "已绑定,无需重复操作" + case "监督人已达上限": + msg = "对方监督人已达上限(最多 3 人)" } } c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, msg)) diff --git a/internal/quitcheckin/service/supervisor.go b/internal/quitcheckin/service/supervisor.go index e9d5f58..ef8b1f8 100644 --- a/internal/quitcheckin/service/supervisor.go +++ b/internal/quitcheckin/service/supervisor.go @@ -40,14 +40,17 @@ type SupervisorStatusResult struct { } var ( - ErrInviteNotFound = errors.New("邀请不存在") - ErrInviteExpired = errors.New("邀请已过期") - ErrInviteUsed = errors.New("邀请已被使用") - ErrCannotBindSelf = errors.New("不能绑定自己为监督人") - ErrBindingExists = errors.New("监督关系已存在") - ErrBindingNotFound = errors.New("监督关系不存在") + ErrInviteNotFound = errors.New("邀请不存在") + ErrInviteExpired = errors.New("邀请已过期") + ErrInviteUsed = errors.New("邀请已被使用") + ErrCannotBindSelf = errors.New("不能绑定自己为监督人") + ErrBindingExists = errors.New("监督关系已存在") + ErrBindingNotFound = errors.New("监督关系不存在") + ErrSupervisorLimitReached = errors.New("监督人已达上限") ) +const maxSupervisorsPerOwner = 3 + func (s *Service) CreateSupervisorInvite(ctx context.Context, ownerUID int, now time.Time, days int) (SupervisorInviteResult, error) { if days <= 0 { days = 7 @@ -113,6 +116,18 @@ func (s *Service) BindSupervisorInvite(ctx context.Context, supervisorUID int, t return err } + // 多人监督:允许多个 supervisor,但限制最多 3 个 active supervisor。 + var activeCount int64 + if err := tx.WithContext(ctx). + Model(&quitmodel.SupervisorBinding{}). + Where("owner_uid = ? AND status = ?", invite.OwnerUID, "active"). + Count(&activeCount).Error; err != nil { + return err + } + if activeCount >= maxSupervisorsPerOwner { + return ErrSupervisorLimitReached + } + binding := quitmodel.SupervisorBinding{ OwnerUID: invite.OwnerUID, SupervisorUID: supervisorUID, diff --git a/internal/quitcheckin/service/supervisor_test.go b/internal/quitcheckin/service/supervisor_test.go index 1f4552e..95226ce 100644 --- a/internal/quitcheckin/service/supervisor_test.go +++ b/internal/quitcheckin/service/supervisor_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "testing" "time" @@ -51,7 +52,6 @@ func TestSupervisorInviteBindAndOverview(t *testing.T) { supervisorUID := 3002 now := time.Date(2026, 4, 16, 10, 0, 0, 0, time.Local) - // seed users if err := db.Create(&usermodel.User{ID: uint(ownerUID), NickName: "owner"}).Error; err != nil { t.Fatalf("seed owner user: %v", err) } @@ -150,3 +150,55 @@ func TestSupervisorRevokeBindingBySupervisor(t *testing.T) { t.Fatalf("overview items=%d, want=0 after revoke", len(overview.Items)) } } + +func TestSupervisorBindRespectsMaxSupervisors(t *testing.T) { + t.Parallel() + + db := setupSupervisorTestDB(t) + svc := NewService(db) + ctx := context.Background() + + ownerUID := 3201 + now := time.Date(2026, 4, 16, 10, 0, 0, 0, time.Local) + + if err := db.Create(&usermodel.User{ID: uint(ownerUID), NickName: "owner"}).Error; err != nil { + t.Fatalf("seed owner user: %v", err) + } + + startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local) + if _, err := svc.UpsertProfile(ctx, ownerUID, UpsertProfileRequest{ + QuitStartDate: &startDate, + PackPriceCent: intPtr(2500), + BaselineCigsPerDay: intPtr(10), + }, "owner", "", now); err != nil { + t.Fatalf("upsert profile: %v", err) + } + + for i := 0; i < 3; i++ { + supervisorUID := 3300 + i + if err := db.Create(&usermodel.User{ID: uint(supervisorUID), NickName: "s"}).Error; err != nil { + t.Fatalf("seed supervisor user: %v", err) + } + invite, err := svc.CreateSupervisorInvite(ctx, ownerUID, now, 7) + if err != nil { + t.Fatalf("create invite: %v", err) + } + if err := svc.BindSupervisorInvite(ctx, supervisorUID, invite.Token, now); err != nil { + t.Fatalf("bind #%d: %v", i+1, err) + } + } + + supervisorUID := 3399 + if err := db.Create(&usermodel.User{ID: uint(supervisorUID), NickName: "s4"}).Error; err != nil { + t.Fatalf("seed supervisor user: %v", err) + } + invite, err := svc.CreateSupervisorInvite(ctx, ownerUID, now, 7) + if err != nil { + t.Fatalf("create invite: %v", err) + } + if err := svc.BindSupervisorInvite(ctx, supervisorUID, invite.Token, now); err == nil { + t.Fatalf("bind #4 should fail") + } else if !errors.Is(err, ErrSupervisorLimitReached) { + t.Fatalf("bind #4 err=%v, want ErrSupervisorLimitReached", err) + } +}