Files
digital-psychology/apps/api/internal/scalebank/bank.go
T
jackyu66gitandCursor 89756f65b4 feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:27:58 +08:00

267 lines
6.1 KiB
Go

package scalebank
import (
_ "embed"
"encoding/json"
"fmt"
"strconv"
"sync"
"github.com/google/uuid"
)
//go:embed curated.json
var curatedJSON []byte
var (
bankNS = uuid.MustParse("6b1e9c2a-4d5f-4a8b-9c0d-1e2f3a4b5c6d")
once sync.Once
root *Root
loadErr error
)
// Root is the curated bank file.
type Root struct {
Version int `json:"version"`
Categories []Category `json:"categories"`
Scales []Scale `json:"scales"`
}
// Category groups scales for explore UI.
type Category struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Slugs []string `json:"slugs"`
Featured []string `json:"featured"`
}
// Scale is one exploration instrument.
type Scale struct {
Slug string `json:"slug"`
Category string `json:"category"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
QuestionCount int `json:"question_count"`
Questions []Question `json:"questions"`
Disclaimer string `json:"disclaimer"`
}
// Question is prompt + options.
type Question struct {
Prompt string `json:"prompt"`
Options []Option `json:"options"`
}
// Option is a choice.
type Option struct {
Key string `json:"key"`
Text string `json:"text"`
}
// CatalogOut is explore bank payload.
type CatalogOut struct {
Featured []FeaturedItem `json:"featured"`
Categories []CategoryCard `json:"categories"`
}
// FeaturedItem is icon+title tile.
type FeaturedItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
CategoryKey string `json:"category_key"`
Icon string `json:"icon"`
Path string `json:"path"`
}
// CategoryCard is a category row / detail header.
type CategoryCard struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Count int `json:"count"`
Featured []FeaturedItem `json:"featured"`
Path string `json:"path"`
}
// ScaleListItem is a scale in a category list.
type ScaleListItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
QuestionCount int `json:"question_count"`
Path string `json:"path"`
}
func load() (*Root, error) {
once.Do(func() {
var r Root
if err := json.Unmarshal(curatedJSON, &r); err != nil {
loadErr = err
return
}
root = &r
})
return root, loadErr
}
// MustLoad panics only in tests if embed broken; production returns error via helpers.
func MustLoad() *Root {
r, err := load()
if err != nil || r == nil {
panic(fmt.Sprintf("scalebank: %v", err))
}
return r
}
func bySlug(r *Root) map[string]Scale {
m := make(map[string]Scale, len(r.Scales))
for _, s := range r.Scales {
m[s.Slug] = s
}
return m
}
func catIcon(r *Root, key string) string {
for _, c := range r.Categories {
if c.Key == key {
return c.Icon
}
}
return "mbti"
}
func scaleIcon(s Scale) string {
if s.Icon != "" {
return s.Icon
}
return "mbti"
}
// Has reports whether slug is in the curated bank.
func Has(slug string) bool {
r, err := load()
if err != nil || r == nil {
return false
}
_, ok := bySlug(r)[slug]
return ok
}
// Catalog builds featured tiles + category cards.
func Catalog() (*CatalogOut, error) {
r, err := load()
if err != nil {
return nil, err
}
m := bySlug(r)
out := &CatalogOut{}
for _, c := range r.Categories {
card := CategoryCard{
Key: c.Key, Title: c.Title, Description: c.Description, Icon: c.Icon,
Count: len(c.Slugs), Path: "/explore/bank/" + c.Key,
}
for _, slug := range c.Featured {
s, ok := m[slug]
if !ok {
continue
}
item := FeaturedItem{
Slug: s.Slug, Title: s.Title, Description: s.Description,
CategoryKey: c.Key, Icon: scaleIcon(s), Path: "/scales/" + s.Slug,
}
card.Featured = append(card.Featured, item)
out.Featured = append(out.Featured, item)
}
out.Categories = append(out.Categories, card)
}
return out, nil
}
// CategoryScales lists scales in a category.
func CategoryScales(key string) (*CategoryCard, []ScaleListItem, error) {
r, err := load()
if err != nil {
return nil, nil, err
}
m := bySlug(r)
for _, c := range r.Categories {
if c.Key != key {
continue
}
card := CategoryCard{
Key: c.Key, Title: c.Title, Description: c.Description, Icon: c.Icon,
Count: len(c.Slugs), Path: "/explore/bank/" + c.Key,
}
items := make([]ScaleListItem, 0, len(c.Slugs))
for _, slug := range c.Slugs {
s, ok := m[slug]
if !ok {
continue
}
items = append(items, ScaleListItem{
Slug: s.Slug, Title: s.Title, Description: s.Description, Icon: scaleIcon(s),
QuestionCount: s.QuestionCount, Path: "/scales/" + s.Slug,
})
}
return &card, items, nil
}
return nil, nil, fmt.Errorf("category not found")
}
// Get returns scale detail questions in repository-compatible shape fields.
func Get(slug string) (*Scale, error) {
r, err := load()
if err != nil {
return nil, err
}
s, ok := bySlug(r)[slug]
if !ok {
return nil, fmt.Errorf("scale not found")
}
cp := s
return &cp, nil
}
// QuestionID stable id for answer keys.
func QuestionID(slug string, index int) uuid.UUID {
return uuid.NewSHA1(bankNS, []byte(fmt.Sprintf("%s/%d", slug, index)))
}
// ScoreSum maps option keys (1..n) to a soft exploration band.
func ScoreSum(answers map[string]string, questionCount int) (styleKey, label string, score, ceiling int) {
sum := 0
maxOpt := 1
for _, v := range answers {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
continue
}
sum += n
if n > maxOpt {
maxOpt = n
}
}
ceiling = questionCount * maxOpt
if ceiling <= 0 {
ceiling = questionCount * 5
}
pct := 0
if ceiling > 0 {
pct = sum * 100 / ceiling
}
switch {
case pct >= 75:
return "high", "感受偏强烈型", sum, ceiling
case pct >= 45:
return "mid", "节奏适中型", sum, ceiling
default:
return "low", "感受偏轻柔型", sum, ceiling
}
}