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:
@@ -0,0 +1,266 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package scalebank
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCatalogCuratedASet(t *testing.T) {
|
||||
out, err := Catalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Categories) != 4 {
|
||||
t.Fatalf("cats=%d", len(out.Categories))
|
||||
}
|
||||
if len(out.Featured) < 6 {
|
||||
t.Fatalf("featured=%d", len(out.Featured))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, f := range out.Featured {
|
||||
if f.Icon == "" || seen[f.Icon] {
|
||||
t.Fatalf("featured icons must be unique nonempty: %#v", out.Featured)
|
||||
}
|
||||
seen[f.Icon] = true
|
||||
}
|
||||
if !Has("mbti") || !Has("gd3") || Has("yyzcs") {
|
||||
t.Fatal("unexpected slug set")
|
||||
}
|
||||
s, err := Get("eq1")
|
||||
if err != nil || len(s.Questions) == 0 {
|
||||
t.Fatalf("eq1 %#v %v", s, err)
|
||||
}
|
||||
key, label, sum, ceil := ScoreSum(map[string]string{"a": "3", "b": "4"}, 2)
|
||||
if key == "" || label == "" || sum == 0 || ceil == 0 {
|
||||
t.Fatalf("%s %s %d %d", key, label, sum, ceil)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user