feat(supervisor): support revoke binding and tighten access

This commit is contained in:
nepiedg
2026-04-16 11:55:00 +08:00
parent 0eaf3a206a
commit 55d84576f3
4 changed files with 103 additions and 5 deletions
@@ -94,3 +94,26 @@ func (h *Handler) GetSupervisorStatus(c *gin.Context) {
} }
c.JSON(http.StatusOK, model.Success(res)) 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}))
}
+31 -5
View File
@@ -40,11 +40,12 @@ type SupervisorStatusResult struct {
} }
var ( var (
ErrInviteNotFound = errors.New("邀请不存在") ErrInviteNotFound = errors.New("邀请不存在")
ErrInviteExpired = errors.New("邀请已过期") ErrInviteExpired = errors.New("邀请已过期")
ErrInviteUsed = errors.New("邀请已被使用") ErrInviteUsed = errors.New("邀请已被使用")
ErrCannotBindSelf = errors.New("不能绑定自己为监督人") ErrCannotBindSelf = errors.New("不能绑定自己为监督人")
ErrBindingExists = errors.New("监督关系已存在") ErrBindingExists = errors.New("监督关系已存在")
ErrBindingNotFound = errors.New("监督关系不存在")
) )
func (s *Service) CreateSupervisorInvite(ctx context.Context, ownerUID int, now time.Time, days int) (SupervisorInviteResult, error) { 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 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 { func newInviteToken() string {
// 80-bit random -> base32 -> ~16 chars, URL safe-ish, upper-case; unify to lower for nicer display. // 80-bit random -> base32 -> ~16 chars, URL safe-ish, upper-case; unify to lower for nicer display.
buf := make([]byte, 10) buf := make([]byte, 10)
@@ -102,3 +102,51 @@ func TestSupervisorInviteBindAndOverview(t *testing.T) {
t.Fatalf("status=%v, want one supervisor uid=%d", status.Items, supervisorUID) 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))
}
}
+1
View File
@@ -19,6 +19,7 @@ func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinh
v2.POST("/supervisor/invites", handler.CreateSupervisorInvite) v2.POST("/supervisor/invites", handler.CreateSupervisorInvite)
v2.POST("/supervisor/bind", handler.BindSupervisorInvite) v2.POST("/supervisor/bind", handler.BindSupervisorInvite)
v2.POST("/supervisor/revoke", handler.RevokeSupervisorBinding)
v2.GET("/supervisor/overview", handler.GetSupervisorOverview) v2.GET("/supervisor/overview", handler.GetSupervisorOverview)
v2.GET("/supervisor/status", handler.GetSupervisorStatus) v2.GET("/supervisor/status", handler.GetSupervisorStatus)