package scale import ( "context" "encoding/json" "errors" "github.com/google/uuid" sc "github.com/yuxingu/digital-psychology/apps/api/internal/scale" "github.com/yuxingu/digital-psychology/apps/api/internal/scalebank" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" ) // ErrMembershipRequired when full MBTI requires growth membership. var ErrMembershipRequired = errors.New("membership required") // MembershipChecker reports whether成长会员 is active. type MembershipChecker interface { IsActive(ctx context.Context, userID uuid.UUID) (bool, error) } // Service serves 探索测试. type Service struct { Repo *repository.ScaleRepo Profiles *repository.ProfileRepo Membership MembershipChecker } // List returns published scales. func (s *Service) List(ctx context.Context) ([]repository.ScaleListItem, error) { return s.Repo.ListPublished(ctx) } // Get returns scale detail; bank slugs served from curated embed; mbti-full may be locked. func (s *Service) Get(ctx context.Context, userID uuid.UUID, slug string) (*repository.ScaleDetail, error) { if bank, err := scalebank.Get(slug); err == nil { if _, err := s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description); err != nil { return nil, errors.New("scale not found") } d := &repository.ScaleDetail{ Slug: bank.Slug, Title: bank.Title, Description: bank.Description, Access: "free", } for i, q := range bank.Questions { body, _ := json.Marshal(map[string]any{ "prompt": q.Prompt, "options": q.Options, }) d.Questions = append(d.Questions, repository.ScaleQuestion{ ID: scalebank.QuestionID(slug, i), Sort: i + 1, Body: body, }) } return d, nil } d, err := s.Repo.GetBySlug(ctx, slug) if err != nil { return nil, errors.New("scale not found") } if slug == "mbti-full" { d.Access = "membership" ok := false if s.Membership != nil && userID != uuid.Nil { ok, _ = s.Membership.IsActive(ctx, userID) } if !ok { d.Locked = true d.Questions = nil } } else { d.Access = "free" } return d, nil } // SubmitInput answers map question_id -> option key. type SubmitInput struct { ProfileID uuid.UUID Answers map[string]string } // SubmitResult is exploration result. type SubmitResult struct { ID uuid.UUID `json:"id"` Result map[string]interface{} `json:"result"` } // Submit scores answers and stores result. func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in SubmitInput) (*SubmitResult, error) { if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil { return nil, errors.New("profile not found") } if slug == "mbti-full" { ok := false if s.Membership != nil { ok, _ = s.Membership.IsActive(ctx, userID) } if !ok { return nil, ErrMembershipRequired } } var result map[string]interface{} var scaleID uuid.UUID if bank, err := scalebank.Get(slug); err == nil { scaleID, err = s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description) if err != nil { return nil, errors.New("scale not found") } key, label, _, _ := scalebank.ScoreSum(in.Answers, bank.QuestionCount) result = sc.BuildResult(slug, key, label) result["disclaimer"] = bank.Disclaimer } else { detail, err := s.Repo.GetBySlug(ctx, slug) if err != nil { return nil, errors.New("scale not found") } scaleID, err = s.Repo.ScaleIDBySlug(ctx, slug) if err != nil { return nil, errors.New("scale not found") } if slug == "mbti-lite" || slug == "mbti-full" { meta := map[string]sc.JungianQuestionMeta{} for _, q := range detail.Questions { meta[q.ID.String()] = sc.ParseJungianMeta(q.Body) } perDim := 8 if slug == "mbti-full" { perDim = 15 } code, _, pct := sc.ScoreJungian(in.Answers, meta, perDim) result = sc.BuildJungianResult(code, pct) } else { labels, _, _ := labelsForSlug(slug) key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型") result = sc.BuildResult(slug, key, label) } } ansJSON, _ := json.Marshal(in.Answers) resJSON, _ := json.Marshal(result) id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON) if err != nil { return nil, err } return &SubmitResult{ID: id, Result: result}, nil } // LatestResult returns the newest stored result for this user + slug. func (s *Service) LatestResult(ctx context.Context, userID uuid.UUID, slug string) (*SubmitResult, error) { if scalebank.Has(slug) { bank, err := scalebank.Get(slug) if err != nil { return nil, errors.New("scale not found") } if _, err := s.Repo.EnsurePublished(ctx, bank.Slug, bank.Title, bank.Description); err != nil { return nil, errors.New("scale not found") } } else if _, err := s.Repo.ScaleIDBySlug(ctx, slug); err != nil { return nil, errors.New("scale not found") } row, err := s.Repo.LatestResult(ctx, userID, slug) if err != nil { return nil, err } var result map[string]interface{} if err := json.Unmarshal(row.Result, &result); err != nil { return nil, errors.New("invalid result") } return &SubmitResult{ID: row.ID, Result: result}, nil } func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary string) { switch slug { case "emotion-pattern": return sc.EmotionLabels(), "我的情感模式:", "这是你当前情绪调节偏好的探索结果,可用于自我了解与日常调整,不是固定标签。" case "enneagram-lite": return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"}, "我的动机模式:", "内在动机轻量探索,用于自我觉察。" case "bigfive-lite": return map[string]string{"O": "开放探索型", "C": "条理稳健型", "E": "主动外展型", "A": "温和协作型", "N": "细腻敏感型"}, "我的性格五维:", "稳定特质速览,可随情境变化。" case "love-style": return map[string]string{"A": "稳定陪伴型", "B": "深度共鸣型", "C": "空间尊重型"}, "我的亲密互动:", "亲密关系偏好探索,不是关系定论。" case "eq-lite": return map[string]string{"A": "觉察表达型", "B": "行动调节型", "C": "缓慢识别型"}, "我的情绪觉察:", "情绪习惯探索,可用于日常调节。" case "stress-index": return map[string]string{"A": "节奏稳定型", "B": "波动调节型", "C": "高负荷需休息型"}, "我的压力负荷:", "近期压力与恢复偏好,不是诊断。" case "career-interest": return map[string]string{"A": "创造表达型", "B": "助人支持型", "C": "分析解决型"}, "我的职业兴趣:", "工作动力偏好探索,可作方向参考。" default: return sc.CommunicationLabels(), "我的沟通方式:", "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。" } } // BankCatalog returns curated exploration bank tiles + categories. func (s *Service) BankCatalog() (*scalebank.CatalogOut, error) { return scalebank.Catalog() } // BankCategory lists scales in a curated category. func (s *Service) BankCategory(key string) (*scalebank.CategoryCard, []scalebank.ScaleListItem, error) { return scalebank.CategoryScales(key) }