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))
|
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}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user