diff --git a/cmd/api/main.go b/cmd/api/main.go index e70db76..1e00f61 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -89,6 +89,8 @@ func main() { &quitcheckinmodel.DailyStatus{}, &quitcheckinmodel.RelapseEvent{}, &quitcheckinmodel.HPChangeLog{}, + &quitcheckinmodel.SupervisorInvite{}, + &quitcheckinmodel.SupervisorBinding{}, &quitcheckinmodel.RewardGoal{}, &quitcheckinmodel.DreamPreset{}, &achievement.Theme{}, diff --git a/internal/quitcheckin/handler/supervisor_handler.go b/internal/quitcheckin/handler/supervisor_handler.go new file mode 100644 index 0000000..3aab5d1 --- /dev/null +++ b/internal/quitcheckin/handler/supervisor_handler.go @@ -0,0 +1,96 @@ +package handler + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "wx_service/internal/middleware" + "wx_service/internal/model" +) + +type createSupervisorInviteRequest struct { + Days int `json:"days"` +} + +// CreateSupervisorInvite POST /api/v2/supervisor/invites +func (h *Handler) CreateSupervisorInvite(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var req createSupervisorInviteRequest + _ = c.ShouldBindJSON(&req) + + res, err := h.service.CreateSupervisorInvite(c.Request.Context(), int(user.ID), time.Now(), req.Days) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成邀请失败,请稍后重试")) + return + } + c.JSON(http.StatusOK, model.Success(res)) +} + +type bindSupervisorInviteRequest struct { + Token string `json:"token"` +} + +// BindSupervisorInvite POST /api/v2/supervisor/bind +func (h *Handler) BindSupervisorInvite(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var req bindSupervisorInviteRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + if err := h.service.BindSupervisorInvite(c.Request.Context(), int(user.ID), req.Token, time.Now()); err != nil { + msg := "绑定失败,请稍后重试" + switch err { + case nil: + // no-op + default: + // 针对业务错误给出更友好的提示 + switch err.Error() { + case "邀请不存在": + msg = "邀请不存在" + case "邀请已过期": + msg = "邀请已过期" + case "邀请已被使用": + msg = "邀请已被使用" + case "不能绑定自己为监督人": + msg = "不能绑定自己" + case "监督关系已存在": + msg = "已绑定,无需重复操作" + } + } + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, msg)) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{"ok": true})) +} + +// GetSupervisorOverview GET /api/v2/supervisor/overview +func (h *Handler) GetSupervisorOverview(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + res, err := h.service.GetSupervisorOverview(c.Request.Context(), int(user.ID), time.Now()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取监督概览失败,请稍后重试")) + return + } + c.JSON(http.StatusOK, model.Success(res)) +} + +// GetSupervisorStatus GET /api/v2/supervisor/status +func (h *Handler) GetSupervisorStatus(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + res, err := h.service.GetSupervisorStatus(c.Request.Context(), int(user.ID)) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取监督信息失败,请稍后重试")) + return + } + c.JSON(http.StatusOK, model.Success(res)) +} diff --git a/internal/quitcheckin/model/supervisor_binding.go b/internal/quitcheckin/model/supervisor_binding.go new file mode 100644 index 0000000..af132cd --- /dev/null +++ b/internal/quitcheckin/model/supervisor_binding.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// SupervisorBinding 表示监督关系(一对:owner -> supervisor)。 +type SupervisorBinding struct { + ID uint `gorm:"primaryKey;comment:主键" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` + + OwnerUID int `gorm:"column:owner_uid;uniqueIndex:uniq_owner_supervisor;index;comment:被监督用户ID" json:"owner_uid"` + SupervisorUID int `gorm:"column:supervisor_uid;uniqueIndex:uniq_owner_supervisor;index;comment:监督人用户ID" json:"supervisor_uid"` + + Status string `gorm:"column:status;size:16;index;comment:状态(active|revoked)" json:"status"` +} + +func (SupervisorBinding) TableName() string { + return "fa_quit_checkin_supervisor_binding" +} + +func (SupervisorBinding) TableComment() string { + return "V2-无烟打卡-监督关系" +} diff --git a/internal/quitcheckin/model/supervisor_invite.go b/internal/quitcheckin/model/supervisor_invite.go new file mode 100644 index 0000000..104525f --- /dev/null +++ b/internal/quitcheckin/model/supervisor_invite.go @@ -0,0 +1,31 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// SupervisorInvite 表示一次“邀请监督人”的邀请记录。 +// 邀请通过 token 进行一次性绑定:被接受后 used_at/used_by_uid 会被写入。 +type SupervisorInvite struct { + ID uint `gorm:"primaryKey;comment:主键" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` + + OwnerUID int `gorm:"column:owner_uid;index;comment:被监督用户ID" json:"-"` + + Token string `gorm:"column:token;size:64;uniqueIndex;comment:邀请token" json:"token"` + ExpireAt time.Time `gorm:"column:expire_at;comment:过期时间" json:"expire_at"` + UsedAt *time.Time `gorm:"column:used_at;comment:被使用时间" json:"used_at,omitempty"` + UsedByUID *int `gorm:"column:used_by_uid;comment:使用者(监督人)用户ID" json:"used_by_uid,omitempty"` +} + +func (SupervisorInvite) TableName() string { + return "fa_quit_checkin_supervisor_invite" +} + +func (SupervisorInvite) TableComment() string { + return "V2-无烟打卡-监督人邀请" +} diff --git a/internal/quitcheckin/service/supervisor.go b/internal/quitcheckin/service/supervisor.go new file mode 100644 index 0000000..76897f5 --- /dev/null +++ b/internal/quitcheckin/service/supervisor.go @@ -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) +} diff --git a/internal/quitcheckin/service/supervisor_test.go b/internal/quitcheckin/service/supervisor_test.go new file mode 100644 index 0000000..4b2d6d5 --- /dev/null +++ b/internal/quitcheckin/service/supervisor_test.go @@ -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) + } +} diff --git a/internal/routes/quitcheckin_routes.go b/internal/routes/quitcheckin_routes.go index a151282..1fda06b 100644 --- a/internal/routes/quitcheckin_routes.go +++ b/internal/routes/quitcheckin_routes.go @@ -17,6 +17,11 @@ func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinh v2.POST("/checkin/check", handler.Checkin) v2.POST("/checkin/relapse", handler.Relapse) + v2.POST("/supervisor/invites", handler.CreateSupervisorInvite) + v2.POST("/supervisor/bind", handler.BindSupervisorInvite) + v2.GET("/supervisor/overview", handler.GetSupervisorOverview) + v2.GET("/supervisor/status", handler.GetSupervisorStatus) + v2.GET("/stats/overview", handler.StatsOverview) v2.GET("/badges", handler.ListBadges) v2.GET("/relapses", handler.ListRelapses)