落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
6.1 KiB
Go
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
|
|
}
|
|
}
|