feat(supervisor): support revoke binding and tighten access
This commit is contained in:
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ var (
|
||||
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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user