package service import ( "context" "crypto/rand" "encoding/base32" "errors" "fmt" "strings" "time" usermodel "wx_service/internal/model" quitmodel "wx_service/internal/quitcheckin/model" "gorm.io/gorm" ) type SupervisorInviteResult struct { Token string `json:"token"` ExpireAt string `json:"expire_at"` } type SupervisorOwnerSummary struct { Owner userSummary `json:"owner"` Home HomeResult `json:"home"` } type userSummary struct { UserID int `json:"user_id"` Nickname string `json:"nickname,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` } type SupervisorOverviewResult struct { Items []SupervisorOwnerSummary `json:"items"` } type SupervisorStatusResult struct { Items []userSummary `json:"items"` } var ( 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 } if days > 30 { days = 30 } token := newInviteToken() expireAt := now.Add(time.Duration(days) * 24 * time.Hour) row := quitmodel.SupervisorInvite{ OwnerUID: ownerUID, Token: token, ExpireAt: expireAt, UsedAt: nil, UsedByUID: nil, } if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { return SupervisorInviteResult{}, fmt.Errorf("create invite: %w", err) } return SupervisorInviteResult{ Token: token, ExpireAt: expireAt.Format(time.RFC3339), }, nil } func (s *Service) BindSupervisorInvite(ctx context.Context, supervisorUID int, token string, now time.Time) error { token = strings.TrimSpace(token) if token == "" { return ErrInviteNotFound } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var invite quitmodel.SupervisorInvite if err := tx.WithContext(ctx).Where("token = ?", token).First(&invite).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrInviteNotFound } return err } if invite.UsedAt != nil || invite.UsedByUID != nil { return ErrInviteUsed } if now.After(invite.ExpireAt) { return ErrInviteExpired } if invite.OwnerUID == supervisorUID { return ErrCannotBindSelf } // 检查是否已存在绑定 var existing quitmodel.SupervisorBinding err := tx.WithContext(ctx). Where("owner_uid = ? AND supervisor_uid = ? AND status = ?", invite.OwnerUID, supervisorUID, "active"). First(&existing).Error if err == nil { return ErrBindingExists } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 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, Status: "active", } if err := tx.WithContext(ctx).Create(&binding).Error; err != nil { return err } usedAt := now usedBy := supervisorUID if err := tx.WithContext(ctx).Model(&quitmodel.SupervisorInvite{}). Where("id = ? AND used_at IS NULL AND used_by_uid IS NULL", invite.ID). Updates(map[string]interface{}{ "used_at": &usedAt, "used_by_uid": &usedBy, }).Error; err != nil { return err } return nil }) } func (s *Service) GetSupervisorOverview(ctx context.Context, supervisorUID int, now time.Time) (SupervisorOverviewResult, error) { var bindings []quitmodel.SupervisorBinding if err := s.db.WithContext(ctx). Where("supervisor_uid = ? AND status = ?", supervisorUID, "active"). Order("id DESC"). Find(&bindings).Error; err != nil { return SupervisorOverviewResult{}, err } if len(bindings) == 0 { return SupervisorOverviewResult{Items: []SupervisorOwnerSummary{}}, nil } ownerIDs := make([]int, 0, len(bindings)) for _, b := range bindings { ownerIDs = append(ownerIDs, b.OwnerUID) } // 拉用户昵称/头像(非强依赖:缺失不影响) userMap := make(map[int]userSummary, len(ownerIDs)) var users []usermodel.User _ = s.db.WithContext(ctx).Where("id IN ?", ownerIDs).Find(&users).Error for _, u := range users { userMap[int(u.ID)] = userSummary{ UserID: int(u.ID), Nickname: u.NickName, AvatarURL: u.AvatarURL, } } items := make([]SupervisorOwnerSummary, 0, len(bindings)) for _, b := range bindings { owner := userMap[b.OwnerUID] if owner.UserID == 0 { owner = userSummary{UserID: b.OwnerUID} } // 只读概览:复用 Home(内部包含 summary + 今日状态) home, err := s.Home(ctx, b.OwnerUID, now) if err != nil { // 对单个 owner 的失败做降级,不影响其他人的展示 continue } // 权限边界:监督视图只展示必要字段,避免泄露备注/梦想目标等更私密的信息。 home.DailyStatus.Note = nil home.Goal = nil items = append(items, SupervisorOwnerSummary{ Owner: owner, Home: home, }) } return SupervisorOverviewResult{Items: items}, nil } func (s *Service) GetSupervisorStatus(ctx context.Context, ownerUID int) (SupervisorStatusResult, error) { var bindings []quitmodel.SupervisorBinding if err := s.db.WithContext(ctx). Where("owner_uid = ? AND status = ?", ownerUID, "active"). Order("id DESC"). Find(&bindings).Error; err != nil { return SupervisorStatusResult{}, err } if len(bindings) == 0 { return SupervisorStatusResult{Items: []userSummary{}}, nil } supervisorIDs := make([]int, 0, len(bindings)) for _, b := range bindings { supervisorIDs = append(supervisorIDs, b.SupervisorUID) } var users []usermodel.User if err := s.db.WithContext(ctx).Where("id IN ?", supervisorIDs).Find(&users).Error; err != nil { return SupervisorStatusResult{}, err } items := make([]userSummary, 0, len(users)) for _, u := range users { items = append(items, userSummary{ UserID: int(u.ID), Nickname: u.NickName, AvatarURL: u.AvatarURL, }) } 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) _, _ = rand.Read(buf) enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf) return strings.ToLower(enc) }