From 55d84576f34de3e453258938c303064abece6007 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Thu, 16 Apr 2026 11:55:00 +0800 Subject: [PATCH] feat(supervisor): support revoke binding and tighten access --- .../quitcheckin/handler/supervisor_handler.go | 23 +++++++++ internal/quitcheckin/service/supervisor.go | 36 ++++++++++++-- .../quitcheckin/service/supervisor_test.go | 48 +++++++++++++++++++ internal/routes/quitcheckin_routes.go | 1 + 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/internal/quitcheckin/handler/supervisor_handler.go b/internal/quitcheckin/handler/supervisor_handler.go index 3aab5d1..4676796 100644 --- a/internal/quitcheckin/handler/supervisor_handler.go +++ b/internal/quitcheckin/handler/supervisor_handler.go @@ -94,3 +94,26 @@ func (h *Handler) GetSupervisorStatus(c *gin.Context) { } c.JSON(http.StatusOK, model.Success(res)) } + +type revokeSupervisorBindingRequest struct { + OwnerUID int `json:"owner_uid"` + SupervisorUID int `json:"supervisor_uid"` +} + +// RevokeSupervisorBinding POST /api/v2/supervisor/revoke +func (h *Handler) RevokeSupervisorBinding(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var req revokeSupervisorBindingRequest + if err := c.ShouldBindJSON(&req); err != nil || req.OwnerUID <= 0 || req.SupervisorUID <= 0 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + if err := h.service.RevokeSupervisorBinding(c.Request.Context(), int(user.ID), req.OwnerUID, req.SupervisorUID, time.Now()); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "解除失败")) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{"ok": true})) +} diff --git a/internal/quitcheckin/service/supervisor.go b/internal/quitcheckin/service/supervisor.go index 76897f5..3d6c076 100644 --- a/internal/quitcheckin/service/supervisor.go +++ b/internal/quitcheckin/service/supervisor.go @@ -40,11 +40,12 @@ type SupervisorStatusResult struct { } var ( - ErrInviteNotFound = errors.New("邀请不存在") - ErrInviteExpired = errors.New("邀请已过期") - ErrInviteUsed = errors.New("邀请已被使用") - ErrCannotBindSelf = errors.New("不能绑定自己为监督人") - ErrBindingExists = errors.New("监督关系已存在") + ErrInviteNotFound = errors.New("邀请不存在") + ErrInviteExpired = errors.New("邀请已过期") + ErrInviteUsed = errors.New("邀请已被使用") + ErrCannotBindSelf = errors.New("不能绑定自己为监督人") + ErrBindingExists = errors.New("监督关系已存在") + ErrBindingNotFound = errors.New("监督关系不存在") ) func (s *Service) CreateSupervisorInvite(ctx context.Context, ownerUID int, now time.Time, days int) (SupervisorInviteResult, error) { @@ -221,6 +222,31 @@ func (s *Service) GetSupervisorStatus(ctx context.Context, ownerUID int) (Superv return SupervisorStatusResult{Items: items}, nil } +// RevokeSupervisorBinding 解除监督关系(owner 或 supervisor 任一方均可解除)。 +func (s *Service) RevokeSupervisorBinding(ctx context.Context, actorUID int, ownerUID int, supervisorUID int, now time.Time) error { + if ownerUID <= 0 || supervisorUID <= 0 { + return ErrBindingNotFound + } + if actorUID != ownerUID && actorUID != supervisorUID { + return ErrBindingNotFound + } + + result := s.db.WithContext(ctx). + Model(&quitmodel.SupervisorBinding{}). + Where("owner_uid = ? AND supervisor_uid = ? AND status = ?", ownerUID, supervisorUID, "active"). + Updates(map[string]interface{}{ + "status": "revoked", + "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrBindingNotFound + } + return nil +} + func newInviteToken() string { // 80-bit random -> base32 -> ~16 chars, URL safe-ish, upper-case; unify to lower for nicer display. buf := make([]byte, 10) diff --git a/internal/quitcheckin/service/supervisor_test.go b/internal/quitcheckin/service/supervisor_test.go index 4b2d6d5..1f4552e 100644 --- a/internal/quitcheckin/service/supervisor_test.go +++ b/internal/quitcheckin/service/supervisor_test.go @@ -102,3 +102,51 @@ func TestSupervisorInviteBindAndOverview(t *testing.T) { t.Fatalf("status=%v, want one supervisor uid=%d", status.Items, supervisorUID) } } + +func TestSupervisorRevokeBindingBySupervisor(t *testing.T) { + t.Parallel() + + db := setupSupervisorTestDB(t) + svc := NewService(db) + ctx := context.Background() + + ownerUID := 3101 + supervisorUID := 3102 + 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) + } + if err := db.Create(&usermodel.User{ID: uint(supervisorUID), NickName: "supervisor"}).Error; err != nil { + t.Fatalf("seed supervisor 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) + } + + 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: %v", err) + } + + if err := svc.RevokeSupervisorBinding(ctx, supervisorUID, ownerUID, supervisorUID, now); err != nil { + t.Fatalf("revoke by supervisor: %v", err) + } + + overview, err := svc.GetSupervisorOverview(ctx, supervisorUID, now) + if err != nil { + t.Fatalf("overview: %v", err) + } + if len(overview.Items) != 0 { + t.Fatalf("overview items=%d, want=0 after revoke", len(overview.Items)) + } +} diff --git a/internal/routes/quitcheckin_routes.go b/internal/routes/quitcheckin_routes.go index 1fda06b..0accc09 100644 --- a/internal/routes/quitcheckin_routes.go +++ b/internal/routes/quitcheckin_routes.go @@ -19,6 +19,7 @@ func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinh v2.POST("/supervisor/invites", handler.CreateSupervisorInvite) v2.POST("/supervisor/bind", handler.BindSupervisorInvite) + v2.POST("/supervisor/revoke", handler.RevokeSupervisorBinding) v2.GET("/supervisor/overview", handler.GetSupervisorOverview) v2.GET("/supervisor/status", handler.GetSupervisorStatus)