feat(supervisor): add invite, bind, and read-only overview

This commit is contained in:
nepiedg
2026-04-16 11:42:53 +08:00
parent a32ec911a1
commit 0eaf3a206a
7 changed files with 496 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
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("监督关系已存在")
)
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
}
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
}
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
}
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)
}
@@ -0,0 +1,104 @@
package service
import (
"context"
"testing"
"time"
usermodel "wx_service/internal/model"
quitmodel "wx_service/internal/quitcheckin/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupSupervisorTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&usermodel.User{},
&quitmodel.Profile{},
&quitmodel.DailyStatus{},
&quitmodel.RelapseEvent{},
&quitmodel.HPChangeLog{},
&quitmodel.SupervisorInvite{},
&quitmodel.SupervisorBinding{},
&quitmodel.RewardGoal{},
&quitmodel.DreamPreset{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return db
}
func TestSupervisorInviteBindAndOverview(t *testing.T) {
t.Parallel()
db := setupSupervisorTestDB(t)
svc := NewService(db)
ctx := context.Background()
ownerUID := 3001
supervisorUID := 3002
now := time.Date(2026, 4, 16, 10, 0, 0, 0, time.Local)
// seed users
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 invite.Token == "" {
t.Fatalf("invite token empty")
}
if err := svc.BindSupervisorInvite(ctx, supervisorUID, invite.Token, now); err != nil {
t.Fatalf("bind: %v", err)
}
overview, err := svc.GetSupervisorOverview(ctx, supervisorUID, now)
if err != nil {
t.Fatalf("overview: %v", err)
}
if len(overview.Items) != 1 {
t.Fatalf("overview items=%d, want=1", len(overview.Items))
}
if overview.Items[0].Owner.UserID != ownerUID {
t.Fatalf("owner uid=%d, want=%d", overview.Items[0].Owner.UserID, ownerUID)
}
if overview.Items[0].Home.Summary.HPCurrent <= 0 {
t.Fatalf("hp_current=%d, want > 0", overview.Items[0].Home.Summary.HPCurrent)
}
status, err := svc.GetSupervisorStatus(ctx, ownerUID)
if err != nil {
t.Fatalf("status: %v", err)
}
if len(status.Items) != 1 || status.Items[0].UserID != supervisorUID {
t.Fatalf("status=%v, want one supervisor uid=%d", status.Items, supervisorUID)
}
}