275 lines
7.6 KiB
Go
275 lines
7.6 KiB
Go
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)
|
|
}
|