feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,13 +8,23 @@ import (
|
||||
"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
|
||||
Repo *repository.ScaleRepo
|
||||
Profiles *repository.ProfileRepo
|
||||
Membership MembershipChecker
|
||||
}
|
||||
|
||||
// List returns published scales.
|
||||
@@ -22,12 +32,42 @@ func (s *Service) List(ctx context.Context) ([]repository.ScaleListItem, error)
|
||||
return s.Repo.ListPublished(ctx)
|
||||
}
|
||||
|
||||
// Get returns scale detail.
|
||||
func (s *Service) Get(ctx context.Context, slug string) (*repository.ScaleDetail, error) {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -43,19 +83,58 @@ type SubmitResult struct {
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
|
||||
// Submit scores a simple majority style.
|
||||
// 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")
|
||||
}
|
||||
scaleID, err := s.Repo.ScaleIDBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, errors.New("scale not found")
|
||||
if slug == "mbti-full" {
|
||||
ok := false
|
||||
if s.Membership != nil {
|
||||
ok, _ = s.Membership.IsActive(ctx, userID)
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrMembershipRequired
|
||||
}
|
||||
}
|
||||
|
||||
labels, _, _ := labelsForSlug(slug)
|
||||
key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型")
|
||||
result := sc.BuildResult(slug, key, label)
|
||||
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)
|
||||
@@ -65,15 +144,36 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in
|
||||
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 "mbti-lite":
|
||||
return map[string]string{"E": "外向充能型", "I": "内向充能型", "T": "理性决策型", "F": "感受决策型", "J": "结构安排型", "P": "弹性探索型"},
|
||||
"我的人格偏好:", "轻量人格偏好探索,不是固定分类。"
|
||||
case "enneagram-lite":
|
||||
return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"},
|
||||
"我的动机模式:", "内在动机轻量探索,用于自我觉察。"
|
||||
@@ -98,3 +198,13 @@ func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary
|
||||
"这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。"
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user