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:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+119
View File
@@ -0,0 +1,119 @@
// Package avatar stores and serves user profile photos.
package avatar
import (
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
const MaxBytes = 2 << 20 // 2 MiB
var (
ErrTooLarge = errors.New("图片过大,请选择 2MB 以内")
ErrBadType = errors.New("仅支持 JPG / PNG / WebP")
ErrEmpty = errors.New("请选择图片")
ErrBadName = errors.New("无效头像")
)
// Store writes multipart image under dir as {userID}{ext}; returns public API path.
func Store(dir string, userID uuid.UUID, fh *multipart.FileHeader) (publicPath string, err error) {
if fh == nil || fh.Size <= 0 {
return "", ErrEmpty
}
if fh.Size > MaxBytes {
return "", ErrTooLarge
}
ext, err := extFor(fh.Filename, fh.Header.Get("Content-Type"))
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
src, err := fh.Open()
if err != nil {
return "", err
}
defer src.Close()
name := userID.String() + ext
dstPath := filepath.Join(dir, name)
tmp := dstPath + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return "", err
}
n, copyErr := io.Copy(out, io.LimitReader(src, MaxBytes+1))
_ = out.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return "", copyErr
}
if n > MaxBytes {
_ = os.Remove(tmp)
return "", ErrTooLarge
}
// remove previous extensions for same user
for _, old := range []string{".jpg", ".jpeg", ".png", ".webp"} {
p := filepath.Join(dir, userID.String()+old)
if p != dstPath {
_ = os.Remove(p)
}
}
if err := os.Rename(tmp, dstPath); err != nil {
_ = os.Remove(tmp)
return "", err
}
return "/api/v1/media/avatars/" + name, nil
}
// ResolveAbs validates file name and returns absolute path under dir.
func ResolveAbs(dir, name string) (string, error) {
base := filepath.Base(name)
if base != name || strings.Contains(base, "..") {
return "", ErrBadName
}
ext := strings.ToLower(filepath.Ext(base))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
return "", ErrBadName
}
idPart := strings.TrimSuffix(base, ext)
if _, err := uuid.Parse(idPart); err != nil {
return "", ErrBadName
}
full := filepath.Join(dir, base)
if _, err := os.Stat(full); err != nil {
return "", ErrBadName
}
return full, nil
}
func extFor(filename, contentType string) (string, error) {
ct := strings.ToLower(contentType)
switch {
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
return ".jpg", nil
case strings.Contains(ct, "png"):
return ".png", nil
case strings.Contains(ct, "webp"):
return ".webp", nil
}
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg":
return ".jpg", nil
case ".png":
return ".png", nil
case ".webp":
return ".webp", nil
default:
return "", fmt.Errorf("%w", ErrBadType)
}
}
+112
View File
@@ -0,0 +1,112 @@
package avatar
import (
"bytes"
"mime/multipart"
"net/textproto"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
)
func TestResolveAbsRejectsTraversal(t *testing.T) {
dir := t.TempDir()
id := uuid.New()
name := id.String() + ".jpg"
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := ResolveAbs(dir, "../"+name); err == nil {
t.Fatal("expected reject ..")
}
if _, err := ResolveAbs(dir, "not-a-uuid.jpg"); err == nil {
t.Fatal("expected reject bad id")
}
if _, err := ResolveAbs(dir, id.String()+".gif"); err == nil {
t.Fatal("expected reject gif")
}
full, err := ResolveAbs(dir, name)
if err != nil {
t.Fatal(err)
}
if filepath.Base(full) != name {
t.Fatalf("full=%s", full)
}
}
func TestStoreRejectsBadTypeAndSize(t *testing.T) {
dir := t.TempDir()
uid := uuid.New()
fhExe := mustFormFile(t, "x.exe", "application/octet-stream", []byte("MZ"))
if _, err := Store(dir, uid, fhExe); err == nil {
t.Fatal("expected bad type")
}
big := bytes.Repeat([]byte("a"), MaxBytes+10)
fhBig := mustFormFile(t, "big.jpg", "image/jpeg", big)
if _, err := Store(dir, uid, fhBig); err == nil {
t.Fatal("expected too large")
}
}
func TestStoreOKAndOverwrite(t *testing.T) {
dir := t.TempDir()
uid := uuid.New()
fh := mustFormFile(t, "a.png", "image/png", []byte{0x89, 0x50, 0x4e, 0x47})
path, err := Store(dir, uid, fh)
if err != nil {
t.Fatal(err)
}
want := "/api/v1/media/avatars/" + uid.String() + ".png"
if path != want {
t.Fatalf("path=%s want=%s", path, want)
}
if _, err := os.Stat(filepath.Join(dir, uid.String()+".png")); err != nil {
t.Fatal(err)
}
fh2 := mustFormFile(t, "b.jpg", "image/jpeg", []byte{0xff, 0xd8, 0xff})
path2, err := Store(dir, uid, fh2)
if err != nil {
t.Fatal(err)
}
if path2 != "/api/v1/media/avatars/"+uid.String()+".jpg" {
t.Fatalf("path2=%s", path2)
}
if _, err := os.Stat(filepath.Join(dir, uid.String()+".png")); !os.IsNotExist(err) {
t.Fatal("old png should be removed")
}
}
func mustFormFile(t *testing.T, filename, contentType string, data []byte) *multipart.FileHeader {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
h.Set("Content-Type", contentType)
part, err := w.CreatePart(h)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
r := multipart.NewReader(&buf, w.Boundary())
form, err := r.ReadForm(int64(len(data)) + 1024)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = form.RemoveAll() })
files := form.File["file"]
if len(files) == 0 {
t.Fatal("no file part")
}
return files[0]
}
+56 -17
View File
@@ -1,6 +1,8 @@
// Package explore provides the L1→L3 explore catalog.
package explore
import "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
// Item is a leaf tool in the catalog.
type Item struct {
Key string `json:"key"`
@@ -22,8 +24,8 @@ type Category struct {
Items []Item `json:"items"`
}
// Catalog returns the full explore tree (deterministic, no DB).
// Order: 四核心优先,量表后置。
// Catalog returns the full explore tree (deterministic base; tests filled by WithPublishedScales).
// Order: 解码 → 量表题库 → 星座 / 匹配 / 节律 …
func Catalog() []Category {
return []Category{
{
@@ -33,6 +35,11 @@ func Catalog() []Category {
{Key: "portrait", Title: "愈心解码", Description: "生日生成性格解码报告", Path: "/portrait", Icon: "△", Tone: "gold", Badge: "热"},
},
},
{
Key: "tests", Title: "量表题库", Description: "人格与偏好测评(已发布)",
Icon: "◆", Tone: "blue",
Items: defaultScaleItems(),
},
{
Key: "star", Title: "星座", Description: "排盘 · 运势 · 合盘",
Icon: "✦", Tone: "night",
@@ -72,24 +79,56 @@ func Catalog() []Category {
{Key: "ask", Title: "AI 成长助手", Description: "结合档案整理感受", Path: "/ask", Icon: "◎", Tone: "gold"},
},
},
{
Key: "tests", Title: "更多测评", Description: "轻量量表(次要入口)",
Icon: "◆", Tone: "blue",
Items: []Item{
{Key: "mbti-lite", Title: "人格类型探索", Description: "能量与决策偏好(轻量)", Path: "/scales/mbti-lite", Icon: "◆", Tone: "blue"},
{Key: "enneagram-lite", Title: "动机模式探索", Description: "内在动机九型向轻测", Path: "/scales/enneagram-lite", Icon: "⑨", Tone: "purple"},
{Key: "bigfive-lite", Title: "性格五维探索", Description: "稳定特质速览", Path: "/scales/bigfive-lite", Icon: "◈", Tone: "teal"},
{Key: "love-style", Title: "亲密互动探索", Description: "亲密关系中的互动偏好", Path: "/scales/love-style", Icon: "♡", Tone: "rose"},
{Key: "eq-lite", Title: "情绪觉察探索", Description: "识别与调节情绪的习惯", Path: "/scales/eq-lite", Icon: "♥", Tone: "pink"},
{Key: "stress-index", Title: "压力负荷探索", Description: "近期压力与恢复方式", Path: "/scales/stress-index", Icon: "☯", Tone: "green"},
{Key: "career-interest", Title: "职业兴趣探索", Description: "工作动力与环境偏好", Path: "/scales/career-interest", Icon: "◎", Tone: "gold"},
{Key: "communication-style", Title: "沟通方式探索", Description: "表达与倾听偏好", Path: "/scales/communication-style", Icon: "◆", Tone: "blue"},
{Key: "emotion-pattern", Title: "情绪模式探索", Description: "情绪起伏与自我照顾", Path: "/scales/emotion-pattern", Icon: "♥", Tone: "pink"},
},
},
}
}
func defaultScaleItems() []Item {
return []Item{
{Key: "mbti-lite", Title: "MBTI测试", Description: "标准版 32 题免费 · 完整版会员", Path: "/scales/mbti-lite", Icon: "◆", Tone: "blue", Badge: "热"},
{Key: "mbti-full", Title: "MBTI完整版", Description: "60 题 · 成长会员", Path: "/scales/mbti-full", Icon: "◆", Tone: "gold"},
{Key: "enneagram-lite", Title: "动机模式探索", Description: "内在动机九型向轻测", Path: "/scales/enneagram-lite", Icon: "⑨", Tone: "purple"},
{Key: "bigfive-lite", Title: "性格五维探索", Description: "稳定特质速览", Path: "/scales/bigfive-lite", Icon: "◈", Tone: "teal"},
{Key: "love-style", Title: "亲密互动探索", Description: "亲密关系中的互动偏好", Path: "/scales/love-style", Icon: "♡", Tone: "rose"},
{Key: "eq-lite", Title: "情绪觉察探索", Description: "识别与调节情绪的习惯", Path: "/scales/eq-lite", Icon: "♥", Tone: "pink"},
{Key: "stress-index", Title: "压力负荷探索", Description: "近期压力与恢复方式", Path: "/scales/stress-index", Icon: "☯", Tone: "green"},
{Key: "career-interest", Title: "职业兴趣探索", Description: "工作动力与环境偏好", Path: "/scales/career-interest", Icon: "◎", Tone: "gold"},
{Key: "communication-style", Title: "沟通方式探索", Description: "表达与倾听偏好", Path: "/scales/communication-style", Icon: "◆", Tone: "blue"},
{Key: "emotion-pattern", Title: "情绪模式探索", Description: "情绪起伏与自我照顾", Path: "/scales/emotion-pattern", Icon: "♥", Tone: "pink"},
}
}
// WithPublishedScales replaces the 量表题库 items with live published scales from DB.
func WithPublishedScales(cats []Category, scales []repository.ScaleListItem) []Category {
if len(scales) == 0 {
return cats
}
items := make([]Item, 0, len(scales))
for _, s := range scales {
badge := ""
tone := "blue"
switch s.Slug {
case "mbti-lite":
badge = "热"
case "mbti-full":
tone = "gold"
}
items = append(items, Item{
Key: s.Slug, Title: s.Title, Description: s.Description,
Path: "/scales/" + s.Slug, Icon: "◆", Tone: tone, Badge: badge,
})
}
out := make([]Category, len(cats))
copy(out, cats)
for i := range out {
if out[i].Key == "tests" {
out[i].Title = "量表题库"
out[i].Description = "人格与偏好测评(已发布)"
out[i].Items = items
}
}
return out
}
// CategoryByKey returns one category or nil.
func CategoryByKey(key string) *Category {
for _, c := range Catalog() {
+22 -42
View File
@@ -1,58 +1,38 @@
package explore
import (
"strings"
"testing"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
func TestCatalogShape(t *testing.T) {
func TestCatalogHasScaleBankNearTop(t *testing.T) {
cats := Catalog()
if len(cats) < 6 {
t.Fatalf("want ≥6 L1 categories, got %d", len(cats))
if len(cats) < 2 {
t.Fatal("expected categories")
}
keys := map[string]bool{}
for _, c := range cats {
keys[c.Key] = true
if len(c.Items) < 1 {
t.Fatalf("category %s empty", c.Key)
}
for _, it := range c.Items {
if it.Path == "" || !strings.HasPrefix(it.Path, "/") {
t.Fatalf("bad path %#v", it)
}
}
if cats[1].Key != "tests" || cats[1].Title != "量表题库" {
t.Fatalf("second cat=%#v want 量表题库", cats[1])
}
for _, k := range []string{"decode", "star", "relation", "tests", "rhythm", "cards", "growth"} {
if !keys[k] {
t.Fatalf("missing category %s", k)
}
}
// 四核心应排在量表之前
if indexOf(cats, "tests") < indexOf(cats, "star") {
t.Fatal("tests should come after star")
if len(cats[1].Items) < 5 {
t.Fatalf("expected scale items, got %d", len(cats[1].Items))
}
}
func indexOf(cats []Category, key string) int {
for i, c := range cats {
if c.Key == key {
return i
}
func TestWithPublishedScalesReplacesItems(t *testing.T) {
cats := Catalog()
out := WithPublishedScales(cats, nil)
if len(out[1].Items) < 5 {
t.Fatal("nil scales should keep defaults")
}
return -1
}
func TestLexiconHardBanOnly(t *testing.T) {
blob := ""
for _, c := range Catalog() {
blob += c.Title + c.Description
for _, it := range c.Items {
blob += it.Title + it.Description
}
out = WithPublishedScales(cats, []repository.ScaleListItem{
{Slug: "mbti-lite", Title: "MBTI测试", Description: "from-db"},
{Slug: "eq-lite", Title: "情绪觉察探索", Description: "eq"},
})
if len(out[1].Items) != 2 {
t.Fatalf("items=%d", len(out[1].Items))
}
for _, bad := range []string{"占卜", "算命", "测测"} {
if strings.Contains(blob, bad) {
t.Fatalf("forbidden %q", bad)
}
if out[1].Items[0].Description != "from-db" || out[1].Items[0].Badge != "热" {
t.Fatalf("unexpected %#v", out[1].Items[0])
}
}
+11 -1
View File
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -11,6 +12,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
@@ -67,6 +69,9 @@ func (h *AskHandler) CreateThread(c *gin.Context) {
ProfileID: pid, Scene: req.Scene,
})
if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 40010, err.Error())
return
}
@@ -142,6 +147,9 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content)
if err != nil {
if failTextCompliance(c, err) {
return
}
if asksvc.IsQuotaExhausted(err) {
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可购买额度或开通成长会员")
return
@@ -180,7 +188,9 @@ func (h *AskHandler) sendMessageStream(c *gin.Context, userID, tid uuid.UUID, co
if err != nil {
msg := err.Error()
code := 40011
if asksvc.IsQuotaExhausted(err) {
if errors.Is(err, textsafe.ErrRejected) {
code = 40060
} else if asksvc.IsQuotaExhausted(err) {
msg = "问答次数已用完,可购买额度或开通成长会员"
code = 40210
}
+27
View File
@@ -24,6 +24,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
g.POST("/logout", h.Logout)
g.GET("/me", h.Me)
g.PATCH("/me", h.PatchMe)
g.POST("/me/avatar", h.UploadAvatar)
}
type authBody struct {
@@ -47,6 +48,9 @@ func (h *AuthHandler) RegisterAccount(c *gin.Context) {
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname)
if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 40110, err.Error())
return
}
@@ -116,12 +120,35 @@ func (h *AuthHandler) PatchMe(c *gin.Context) {
}
me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname)
if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 40113, err.Error())
return
}
response.OK(c, me)
}
// UploadAvatar handles POST /auth/me/avatar (multipart file).
func (h *AuthHandler) UploadAvatar(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
fh, err := c.FormFile("file")
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "请选择图片")
return
}
me, err := h.Svc.UpdateAvatar(c.Request.Context(), userID, fh)
if err != nil {
response.Fail(c, http.StatusBadRequest, 40114, err.Error())
return
}
response.OK(c, me)
}
func bearerToken(c *gin.Context) string {
h := c.GetHeader("Authorization")
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
+3
View File
@@ -55,6 +55,9 @@ func (h *CompanionHandler) SaveMood(c *gin.Context) {
}
m, err := h.Svc.SaveMood(c.Request.Context(), userID, in)
if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 30010, err.Error())
return
}
+20 -2
View File
@@ -6,11 +6,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/explore"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/scale"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ExploreHandler serves the explore catalog.
type ExploreHandler struct{}
type ExploreHandler struct {
Scales *scale.Service
}
// Register mounts explore routes.
func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
@@ -20,7 +23,13 @@ func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
// Catalog handles GET /explore/catalog.
func (h *ExploreHandler) Catalog(c *gin.Context) {
response.OK(c, gin.H{"categories": explore.Catalog()})
cats := explore.Catalog()
if h.Scales != nil {
if items, err := h.Scales.List(c.Request.Context()); err == nil {
cats = explore.WithPublishedScales(cats, items)
}
}
response.OK(c, gin.H{"categories": cats})
}
// Category handles GET /explore/catalog/:key.
@@ -30,5 +39,14 @@ func (h *ExploreHandler) Category(c *gin.Context) {
response.Fail(c, http.StatusNotFound, 40402, "category not found")
return
}
if cat.Key == "tests" && h.Scales != nil {
if items, err := h.Scales.List(c.Request.Context()); err == nil {
enriched := explore.WithPublishedScales([]explore.Category{*cat}, items)
if len(enriched) > 0 {
response.OK(c, enriched[0])
return
}
}
}
response.OK(c, cat)
}
+22 -5
View File
@@ -2,7 +2,6 @@ package handler
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -10,6 +9,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
@@ -57,12 +57,17 @@ func (h *GrowthHandler) CreatePlan(c *gin.Context) {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
title := strings.TrimSpace(req.Title)
if title == "" || len([]rune(title)) > 40 {
response.Fail(c, http.StatusBadRequest, 10000, "invalid title")
title, err := textsafe.Check(textsafe.Title, req.Title)
if err != nil {
_ = failTextCompliance(c, err)
return
}
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, strings.TrimSpace(req.Focus))
focus, err := textsafe.Check(textsafe.Focus, req.Focus)
if err != nil {
_ = failTextCompliance(c, err)
return
}
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, focus)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30020, err.Error())
return
@@ -86,6 +91,18 @@ func (h *GrowthHandler) Checkin(c *gin.Context) {
Note *string `json:"note"`
}
_ = c.ShouldBindJSON(&req)
if req.Note != nil {
n, err := textsafe.Check(textsafe.Note, *req.Note)
if err != nil {
_ = failTextCompliance(c, err)
return
}
if n == "" {
req.Note = nil
} else {
req.Note = &n
}
}
out, err := h.Plans.Checkin(c.Request.Context(), userID, pid, time.Now(), req.Note)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30021, err.Error())
+36
View File
@@ -0,0 +1,36 @@
package handler
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
)
// MediaHandler serves uploaded media files.
type MediaHandler struct {
AvatarDir string
}
// Register mounts public media routes.
func (h *MediaHandler) Register(api *gin.RouterGroup) {
api.GET("/media/avatars/:file", h.ServeAvatar)
}
// ServeAvatar streams a stored avatar image.
func (h *MediaHandler) ServeAvatar(c *gin.Context) {
dir := h.AvatarDir
if dir == "" {
dir = "data/avatars"
}
full, err := avatar.ResolveAbs(dir, c.Param("file"))
if err != nil {
c.Status(http.StatusNotFound)
return
}
c.Header("Cache-Control", "public, max-age=86400")
c.File(full)
_ = filepath.Ext(full)
}
+6
View File
@@ -57,6 +57,9 @@ func (h *ProfileHandler) Create(c *gin.Context) {
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
})
if err != nil {
if failTextCompliance(c, err) {
return
}
if errors.Is(err, profile.ErrSelfExists) {
response.Fail(c, http.StatusConflict, 40902, err.Error())
return
@@ -122,6 +125,9 @@ func (h *ProfileHandler) Update(c *gin.Context) {
GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible,
})
if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusNotFound, 40401, err.Error())
return
}
+49 -1
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -19,10 +20,33 @@ type ScaleHandler struct {
// Register mounts routes.
func (h *ScaleHandler) Register(rg *gin.RouterGroup) {
rg.GET("/scales", h.List)
rg.GET("/scale-bank/catalog", h.BankCatalog)
rg.GET("/scale-bank/categories/:key", h.BankCategory)
rg.GET("/scales/:slug", h.Get)
rg.GET("/scales/:slug/result", h.LatestResult)
rg.POST("/scales/:slug/result", h.Submit)
}
// BankCatalog handles GET /scale-bank/catalog.
func (h *ScaleHandler) BankCatalog(c *gin.Context) {
out, err := h.Svc.BankCatalog()
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50003, "bank catalog failed")
return
}
response.OK(c, out)
}
// BankCategory handles GET /scale-bank/categories/:key.
func (h *ScaleHandler) BankCategory(c *gin.Context) {
cat, items, err := h.Svc.BankCategory(c.Param("key"))
if err != nil {
response.Fail(c, http.StatusNotFound, 40402, "category not found")
return
}
response.OK(c, gin.H{"category": cat, "items": items})
}
// List handles GET /scales.
func (h *ScaleHandler) List(c *gin.Context) {
items, err := h.Svc.List(c.Request.Context())
@@ -35,7 +59,8 @@ func (h *ScaleHandler) List(c *gin.Context) {
// Get handles GET /scales/:slug.
func (h *ScaleHandler) Get(c *gin.Context) {
d, err := h.Svc.Get(c.Request.Context(), c.Param("slug"))
userID, _ := middleware.UserIDFromContext(c)
d, err := h.Svc.Get(c.Request.Context(), userID, c.Param("slug"))
if err != nil {
response.Fail(c, http.StatusNotFound, 40402, err.Error())
return
@@ -43,6 +68,25 @@ func (h *ScaleHandler) Get(c *gin.Context) {
response.OK(c, d)
}
// LatestResult handles GET /scales/:slug/result.
func (h *ScaleHandler) LatestResult(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
out, err := h.Svc.LatestResult(c.Request.Context(), userID, c.Param("slug"))
if err != nil {
if err.Error() == "scale not found" {
response.Fail(c, http.StatusNotFound, 40402, err.Error())
return
}
response.Fail(c, http.StatusNotFound, 40411, "result not found")
return
}
response.OK(c, out)
}
// Submit handles POST /scales/:slug/result.
func (h *ScaleHandler) Submit(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
@@ -67,6 +111,10 @@ func (h *ScaleHandler) Submit(c *gin.Context) {
ProfileID: pid, Answers: req.Answers,
})
if err != nil {
if errors.Is(err, scale.ErrMembershipRequired) {
response.Fail(c, http.StatusForbidden, 40310, "需要成长会员")
return
}
response.Fail(c, http.StatusBadRequest, 30006, err.Error())
return
}
+3
View File
@@ -116,6 +116,9 @@ func (h *SynastryHandler) AcceptInvite(c *gin.Context) {
}
rep, err := h.Svc.AcceptInvite(c.Request.Context(), userID, c.Param("token"), req.DisplayName, req.BirthDate, req.BirthTime, req.BirthPlace)
if err != nil {
if failTextCompliance(c, err) {
return
}
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// failTextCompliance maps textsafe rejections to HTTP 400 / code 40060.
func failTextCompliance(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, textsafe.ErrRejected) {
response.Fail(c, http.StatusBadRequest, 40060, err.Error())
return true
}
return false
}
+5 -3
View File
@@ -54,7 +54,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
membershipSvc := &membership.Service{Reports: reportRepo}
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
scaleRepo := &repository.ScaleRepo{Pool: pool}
scaleSvc := &scale.Service{Repo: scaleRepo, Profiles: profileRepo}
scaleSvc := &scale.Service{Repo: scaleRepo, Profiles: profileRepo, Membership: membershipSvc}
askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm}
companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}}
imageCardSvc := &imagecardsvc.Service{
@@ -64,6 +64,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
}
homeSvc := &homesvc.Service{
Repo: &repository.HomeToolsRepo{Pool: pool},
Tips: &repository.HomeDailyTipsRepo{Pool: pool},
Profiles: profileRepo,
LLM: llm,
}
@@ -71,7 +72,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
adminSvc := &adminsvc.Service{
Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
}
authSvc := &authsvc.Service{Repo: authRepo}
authSvc := &authsvc.Service{Repo: authRepo, AvatarDir: "data/avatars"}
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
Username: cfg.Admin.BootstrapUsername,
Password: cfg.Admin.BootstrapPassword,
@@ -91,6 +92,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
api.GET("/ping", func(c *gin.Context) {
response.OK(c, gin.H{"pong": true})
})
(&handler.MediaHandler{AvatarDir: "data/avatars"}).Register(api)
(&handler.AdminHandler{Svc: adminSvc, Analytics: analyticsSvc}).Register(api)
authed := api.Group("")
@@ -109,7 +111,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
(&handler.AskHandler{Svc: askSvc}).Register(gated)
(&handler.CompanionHandler{Svc: companionSvc}).Register(gated)
(&handler.ImageCardHandler{Svc: imageCardSvc}).Register(gated)
(&handler.ExploreHandler{}).Register(gated)
(&handler.ExploreHandler{Scales: scaleSvc}).Register(gated)
(&handler.GrowthHandler{
Plans: &repository.GrowthRepo{Pool: pool},
}).Register(gated)
@@ -43,3 +43,49 @@ func TestGrowthPlanCheckin(t *testing.T) {
t.Fatal("expected checkin")
}
}
func TestScaleBankResultRoundtrip(t *testing.T) {
r, _ := setupAPI(t)
key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/scale-bank/catalog", nil, key)
cat := decodeData[map[string]any](t, env.Data)
featured, _ := cat["featured"].([]any)
if len(featured) < 1 {
t.Fatal("expected featured bank scales")
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1991-07-07", "display_name": "我",
}, key)
profile := decodeData[map[string]any](t, env.Data)
profileID, _ := profile["id"].(string)
env, key = doJSON(t, r, http.MethodGet, "/api/v1/scales/sm1", nil, key)
detail := decodeData[map[string]any](t, env.Data)
qs, _ := detail["questions"].([]any)
if len(qs) < 1 {
t.Fatal("expected sm1 questions")
}
answers := map[string]string{}
for _, raw := range qs {
q, _ := raw.(map[string]any)
id, _ := q["id"].(string)
answers[id] = "3"
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/scales/sm1/result", map[string]any{
"profile_id": profileID, "answers": answers,
}, key)
submitted := decodeData[map[string]any](t, env.Data)
if submitted["result"] == nil {
t.Fatalf("missing result: %#v", submitted)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/scales/sm1/result", nil, key)
latest := decodeData[map[string]any](t, env.Data)
res, ok := latest["result"].(map[string]any)
if !ok || res["label"] == nil || res["label"] == "" {
t.Fatalf("expected latest label, got %#v", latest)
}
}
+1 -11
View File
@@ -30,7 +30,6 @@ func Build(birth time.Time, displayName string) Output {
bz := getBazi(y, int(m), d, 12)
pct := calc5E(bz)
strong := strongestEl(pct)
weak := weakestEl(pct)
cst := constitutionByElement[strong]
emo := emotionByElement[strong]
@@ -68,16 +67,7 @@ func Build(birth time.Time, displayName string) Output {
"lock_teaser_wuxing": "体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看完整方案。",
"pattern_key": main,
"pyramid": tri.PyramidMap(),
"wuxing": map[string]any{
"bars": wuxingBars(pct),
"primary": cst.Primary,
"strong": cst.Strong,
"weak": cst.Weak,
"strong_el": strong,
"weak_el": weak,
"emotion": emo.Strength,
"care_dir": "养" + cst.Strong + " · 需呵护" + cst.Weak,
},
"wuxing": WuxingBlock(birth),
"today_tip": tip,
"dimensions": dims,
"strengths_preview": []string{
+21
View File
@@ -115,6 +115,27 @@ func wuxingBars(p map[string]int) []map[string]any {
return bars
}
// WuxingBlock builds the shared 五行 summary block (bars + tendency labels).
func WuxingBlock(birth time.Time) map[string]any {
y, m, d := birth.Date()
bz := getBazi(y, int(m), d, 12)
pct := calc5E(bz)
strong := strongestEl(pct)
weak := weakestEl(pct)
cst := constitutionByElement[strong]
emo := emotionByElement[strong]
return map[string]any{
"bars": wuxingBars(pct),
"primary": cst.Primary,
"strong": cst.Strong,
"weak": cst.Weak,
"strong_el": strong,
"weak_el": weak,
"emotion": emo.Strength,
"care_dir": "养" + cst.Strong + " · 需呵护" + cst.Weak,
}
}
func todayTip(now time.Time) map[string]any {
st := companion.TodaySolar(now)
el := seasonEl[int(now.Month())-1]
+14 -12
View File
@@ -21,38 +21,35 @@ type AccountRow struct {
Phone string
PasswordHash string
Nickname string
AvatarURL string
Status string
}
// GetByPhone loads a registered user by phone.
func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
row := &AccountRow{}
var nick *string
err := r.Pool.QueryRow(ctx, `
SELECT id, phone, password_hash, COALESCE(nickname,''), status
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status
FROM users
WHERE phone=$1 AND deleted_at IS NULL`, phone,
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &nick, &row.Status)
).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err != nil {
return nil, err
}
if nick != nil {
row.Nickname = *nick
}
return row, nil
}
// GetAccount loads account fields for a user id.
func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) {
row := &AccountRow{}
var phone, hash, nick *string
var phone, hash *string
err := r.Pool.QueryRow(ctx, `
SELECT id, phone, password_hash, nickname, status
SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status
FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
).Scan(&row.ID, &phone, &hash, &nick, &row.Status)
).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status)
if err != nil {
return nil, err
}
@@ -62,9 +59,6 @@ func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRo
if hash != nil {
row.PasswordHash = *hash
}
if nick != nil {
row.Nickname = *nick
}
return row, nil
}
@@ -105,6 +99,14 @@ func (r *AuthRepo) UpdateNickname(ctx context.Context, userID uuid.UUID, nicknam
return err
}
// UpdateAvatarURL sets users.avatar_url.
func (r *AuthRepo) UpdateAvatarURL(ctx context.Context, userID uuid.UUID, url string) error {
_, err := r.Pool.Exec(ctx, `
UPDATE users SET avatar_url=$2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`, userID, url)
return err
}
// TouchPassword updates stored password hash (open-login record).
func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
_, err := r.Pool.Exec(ctx, `
@@ -0,0 +1,52 @@
package repository
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// HomeDailyTipsRepo persists per-时辰 homepage tips.
type HomeDailyTipsRepo struct {
Pool *pgxpool.Pool
}
// GetTips returns cached tips JSON for user + shichen start.
func (r *HomeDailyTipsRepo) GetTips(ctx context.Context, userID uuid.UUID, start time.Time) (json.RawMessage, string, error) {
var raw json.RawMessage
var source string
err := r.Pool.QueryRow(ctx, `
SELECT tips, source FROM home_daily_tips
WHERE user_id=$1 AND shichen_start=$2`, userID, start,
).Scan(&raw, &source)
if err != nil {
return nil, "", err
}
return raw, source, nil
}
// UpsertTips stores tips for user × shichen.
func (r *HomeDailyTipsRepo) UpsertTips(
ctx context.Context,
userID uuid.UUID,
start time.Time,
shichen int,
tips json.RawMessage,
source string,
) error {
_, err := r.Pool.Exec(ctx, `
INSERT INTO home_daily_tips(user_id, shichen_start, shichen, tips, source)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (user_id, shichen_start) DO UPDATE
SET tips=EXCLUDED.tips, source=EXCLUDED.source, shichen=EXCLUDED.shichen`,
userID, start, shichen, tips, source,
)
return err
}
// ErrNoTips is pgx.ErrNoRows alias for callers.
var ErrNoTips = pgx.ErrNoRows
@@ -68,6 +68,21 @@ func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.
return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail)
}
// UpdateContent patches summary/detail in place (keeps report id for deep access).
func (r *ReportRepo) UpdateContent(ctx context.Context, userID, reportID uuid.UUID, summary, detail json.RawMessage) error {
tag, err := r.Pool.Exec(ctx, `
UPDATE growth_reports SET summary=$3, detail=$4, updated_at=now()
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
reportID, userID, summary, detail)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
// GetLatest returns newest non-deleted report for profile+type(+peer).
func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
rep := &model.GrowthReport{}
@@ -28,6 +28,8 @@ type ScaleDetail struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description"`
Access string `json:"access,omitempty"` // free | membership
Locked bool `json:"locked,omitempty"`
Questions []ScaleQuestion `json:"questions"`
}
@@ -171,6 +173,24 @@ func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID u
return id, err
}
// LatestResult returns the newest result for user + published slug.
func (r *ScaleRepo) LatestResult(ctx context.Context, userID uuid.UUID, slug string) (*ScaleResultRow, error) {
var row ScaleResultRow
err := r.Pool.QueryRow(ctx, `
SELECT sr.id, s.slug, sr.result
FROM scale_results sr
JOIN scales s ON s.id = sr.scale_id
WHERE sr.user_id = $1 AND s.slug = $2 AND s.deleted_at IS NULL
AND sr.deleted_at IS NULL
ORDER BY sr.created_at DESC
LIMIT 1`, userID, slug,
).Scan(&row.ID, &row.ScaleSlug, &row.Result)
if err != nil {
return nil, err
}
return &row, nil
}
// ScaleIDBySlug resolves id for a published scale.
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
var id uuid.UUID
@@ -179,3 +199,20 @@ func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID,
WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id)
return id, err
}
// EnsurePublished upserts scale metadata (curated bank stubs; questions served from embed).
func (r *ScaleRepo) EnsurePublished(ctx context.Context, slug, title, description string) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
INSERT INTO scales (slug, title, description, status)
VALUES ($1,$2,$3,'published')
ON CONFLICT (slug) DO UPDATE SET
title=EXCLUDED.title,
description=EXCLUDED.description,
status='published',
updated_at=now(),
deleted_at=NULL
RETURNING id`, slug, title, description,
).Scan(&id)
return id, err
}
+38 -11
View File
@@ -4,6 +4,9 @@ package rhythm
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/companion"
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
)
// Output free summary + gated detail.
@@ -14,8 +17,13 @@ type Output struct {
var elements = []string{"木", "火", "土", "金", "水"}
// Build from birth date.
// Build from birth date (asOf = now).
func Build(birth time.Time, displayName string) Output {
return BuildWith(birth, displayName, time.Time{})
}
// BuildWith anchors today/week tips to asOf (zero = now CST).
func BuildWith(birth time.Time, displayName string, asOf time.Time) Output {
name := displayName
if name == "" {
name = "你"
@@ -36,9 +44,20 @@ func Build(birth time.Time, displayName string) Output {
{"key": "mood", "title": "情绪调节", "teaser": tip.Mood, "score": 71},
}
todayTip := tip.Sleep
weekday := int(time.Now().Weekday())
when := asOf
if when.IsZero() {
when = time.Now().In(time.FixedZone("CST", 8*3600))
} else {
when = when.In(time.FixedZone("CST", 8*3600))
}
todayTips := []string{tip.Sleep, tip.Diet, tip.Move, tip.Mood}
todayTip := todayTips[when.YearDay()%len(todayTips)]
weekday := int(when.Weekday())
weekHints := []string{tip.Mood, tip.Move, tip.Diet, tip.Sleep, tip.WeekTip, tip.Move, tip.Mood}
dayKey := when.Format("2006-01-02")
validUntil := time.Date(when.Year(), when.Month(), when.Day()+1, 0, 0, 0, 0, when.Location())
wx := portrait.WuxingBlock(birth)
st := companion.TodaySolar(when)
summary := map[string]any{
"title": "身心节律·基础",
"style_label": primary + "倾向",
@@ -48,12 +67,20 @@ func Build(birth time.Time, displayName string) Output {
"life_tip": tip.WeekTip,
"today_tip": todayTip,
"week_focus": weekHints[weekday%len(weekHints)],
"keywords": []string{primary, secondary, "生活建议", "节律"},
"dimensions": dims,
"primary_element": primary,
"secondary_element": secondary,
"strengths_preview": tip.Strengths[:min(3, len(tip.Strengths))],
"blind_spots_preview": []string{"完整习惯方案见深度版。"},
"as_of": dayKey,
"valid_until": validUntil.Format(time.RFC3339),
"wuxing": wx,
"solar_term": map[string]any{
"name": st.Name,
"tip": st.Tip,
},
"keywords": []string{primary, secondary, "生活建议", "节律"},
"dimensions": dims,
"primary_element": primary,
"secondary_element": secondary,
"strengths_preview": tip.Strengths[:min(3, len(tip.Strengths))],
"blind_spots_preview": tip.BlindSpots[:min(3, len(tip.BlindSpots))],
"disclaimer_constitution": "体质与调养内容为生活方式参考,非医疗建议。",
}
detail := map[string]any{
@@ -65,8 +92,8 @@ func Build(birth time.Time, displayName string) Output {
{"title": "活动与放松", "body": tip.MoveDeep, "bullets": tip.MoveBullets},
{"title": "情绪与压力", "body": tip.MoodDeep, "bullets": tip.MoodBullets},
{"title": "与节气联动", "body": "可结合「节气生活」调整当季节奏;变化慢一点,观察身体反馈即可。", "bullets": []string{"查看今日节气", "一次只改一个习惯", "不适就医,勿自行当治疗"}},
{"title": "今日生活建议", "body": tip.Sleep + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}},
{"title": "本周节奏", "body": tip.WeekTip, "bullets": tip.OverviewBullets},
{"title": "今日生活建议", "body": todayTip + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}},
{"title": "本周节奏", "body": weekHints[weekday%len(weekHints)], "bullets": tip.OverviewBullets},
},
"strengths": tip.Strengths,
"blind_spots": tip.BlindSpots,
+24 -9
View File
@@ -1,21 +1,36 @@
package rhythm
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestBuild(t *testing.T) {
out := Build(time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC), "我")
if out.Summary["primary_element"] == nil {
t.Fatal("missing element")
func TestBuildIncludesWuxingBars(t *testing.T) {
birth := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
out := BuildWith(birth, "测", time.Date(2026, 8, 12, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)))
wx, ok := out.Summary["wuxing"].(map[string]any)
if !ok {
t.Fatal("missing wuxing")
}
raw, _ := json.Marshal(out)
for _, bad := range []string{"运势", "吉凶", "算命", "疗效", "治病"} {
if strings.Contains(string(raw), bad) {
t.Fatalf("forbidden %q", bad)
bars, ok := wx["bars"].([]map[string]any)
if !ok || len(bars) != 5 {
t.Fatalf("bars=%#v", wx["bars"])
}
if wx["strong_el"] == nil || wx["care_dir"] == nil {
t.Fatalf("incomplete wuxing: %#v", wx)
}
prev, ok := out.Summary["blind_spots_preview"].([]string)
if !ok || len(prev) == 0 {
t.Fatalf("blind_spots_preview=%#v", out.Summary["blind_spots_preview"])
}
for _, s := range prev {
if strings.Contains(s, "深度版") {
t.Fatalf("preview should not be upsell: %q", s)
}
}
st, ok := out.Summary["solar_term"].(map[string]any)
if !ok || st["name"] == nil {
t.Fatalf("missing solar_term: %#v", out.Summary["solar_term"])
}
}
+122
View File
@@ -0,0 +1,122 @@
package scale
import (
"encoding/json"
"strconv"
)
// JungianQuestionMeta is parsed from scale_questions.body for OEJTS-style items.
type JungianQuestionMeta struct {
Dimension string `json:"dimension"`
Format string `json:"format"`
Left string `json:"left"`
Right string `json:"right"`
Prompt string `json:"prompt"`
}
// ScoreJungian tallies 15 Likert answers per EI/SN/TF/JP (OEJTS rules).
// Left poles: E, S, F, J · Right poles: I, N, T, P · threshold = perDim*3.
func ScoreJungian(answers map[string]string, qMeta map[string]JungianQuestionMeta, perDim int) (typeCode string, raw map[string]int, pct map[string]int) {
raw = map[string]int{"EI": 0, "SN": 0, "TF": 0, "JP": 0}
if perDim <= 0 {
perDim = 8
}
for qid, ans := range answers {
meta, ok := qMeta[qid]
if !ok || meta.Dimension == "" {
continue
}
v, err := strconv.Atoi(ans)
if err != nil || v < 1 || v > 5 {
v = 3
}
raw[meta.Dimension] += v
}
thr := perDim * 3
pick := func(score int, left, right string) string {
if score > thr {
return right
}
return left
}
typeCode = pick(raw["EI"], "E", "I") +
pick(raw["SN"], "S", "N") +
pick(raw["TF"], "F", "T") +
pick(raw["JP"], "J", "P")
pct = map[string]int{}
minS, maxS := perDim, perDim*5
span := float64(maxS - minS)
rightPct := func(score int) int {
p := int(float64(score-minS)/span*100 + 0.5)
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return p
}
eiR := rightPct(raw["EI"])
snR := rightPct(raw["SN"])
tfR := rightPct(raw["TF"])
jpR := rightPct(raw["JP"])
pct["E"], pct["I"] = 100-eiR, eiR
pct["S"], pct["N"] = 100-snR, snR
pct["F"], pct["T"] = 100-tfR, tfR
pct["J"], pct["P"] = 100-jpR, jpR
return typeCode, raw, pct
}
// ParseJungianMeta extracts dimension metadata from question body JSON.
func ParseJungianMeta(body json.RawMessage) JungianQuestionMeta {
var m JungianQuestionMeta
_ = json.Unmarshal(body, &m)
return m
}
// BuildJungianResult builds rich result with 4-letter code + preference bars.
func BuildJungianResult(typeCode string, pct map[string]int) map[string]interface{} {
label := MBTILabel(typeCode)
base := mbtiPack(typeCode, label)
base.Label = typeCode + " · " + label
base.ShareLine = "我的类型探索:" + typeCode + " · " + label
base.Dimensions = []map[string]interface{}{
{"title": "E/I 能量", "score": maxPct(pct, "E", "I"), "note": prefNote("E", "I", pct, "外向互动", "内向思考")},
{"title": "S/N 信息", "score": maxPct(pct, "S", "N"), "note": prefNote("S", "N", pct, "具体事实", "模式可能")},
{"title": "T/F 决策", "score": maxPct(pct, "T", "F"), "note": prefNote("T", "F", pct, "逻辑一致", "价值与人际")},
{"title": "J/P 节奏", "score": maxPct(pct, "J", "P"), "note": prefNote("J", "P", pct, "计划结构", "灵活开放")},
}
out := map[string]interface{}{
"title": "MBTI 探索结果",
"style_key": typeCode,
"type_code": typeCode,
"label": base.Label,
"summary": base.Summary,
"overview": base.Overview,
"share_line": base.ShareLine,
"dimensions": base.Dimensions,
"preferences": pct,
"strengths": base.Strengths,
"watchouts": base.Watchouts,
"tips": base.Tips,
"scripts": base.Scripts,
"growth_plan": base.GrowthPlan,
"faq": base.FAQ,
}
return out
}
func maxPct(pct map[string]int, a, b string) int {
if pct[a] >= pct[b] {
return pct[a]
}
return pct[b]
}
func prefNote(a, b string, pct map[string]int, aDesc, bDesc string) string {
if pct[a] >= pct[b] {
return a + " " + strconv.Itoa(pct[a]) + "% · " + aDesc
}
return b + " " + strconv.Itoa(pct[b]) + "% · " + bDesc
}
+97
View File
@@ -0,0 +1,97 @@
package scale
// mbtiPack returns exploration copy for a 4-letter preference code (原创,非官方题库).
func mbtiPack(code, fallback string) pack {
label := MBTILabel(code)
if label == "平衡探索型" && fallback != "" {
label = fallback
}
base := mbtiBase(code, label)
dims := []map[string]interface{}{
{"title": "能量", "score": dimScore(code, 0, 'E'), "note": axisNote(code, 0)},
{"title": "信息", "score": dimScore(code, 1, 'S'), "note": axisNote(code, 1)},
{"title": "决策", "score": dimScore(code, 2, 'T'), "note": axisNote(code, 2)},
{"title": "节奏", "score": dimScore(code, 3, 'J'), "note": axisNote(code, 3)},
}
base.Dimensions = dims
base.ShareLine = "我的 MBTI 探索:" + code + " · " + label
base.FAQ = append(base.FAQ, map[string]string{
"q": "这是固定人格吗?",
"a": "不是。这是当前情境下的偏好快照,可随经历变化,请当作自我了解工具。",
})
return base
}
func dimScore(code string, i int, left rune) int {
if i >= len(code) {
return 70
}
if rune(code[i]) == left {
return 82
}
return 78
}
func axisNote(code string, i int) string {
if i >= len(code) {
return ""
}
switch code[i] {
case 'E':
return "外向充能偏多"
case 'I':
return "内向充能偏多"
case 'S':
return "更抓具体与当下"
case 'N':
return "更抓可能与意义"
case 'T':
return "更重逻辑与标准"
case 'F':
return "更重感受与关系"
case 'J':
return "更爱结构与收束"
case 'P':
return "更爱弹性与开放"
}
return ""
}
func mbtiBase(code, label string) pack {
switch code {
case "INTJ":
return pack{Label: label, Summary: "你习惯先看见长期结构,再一步步落地。独处思考是你的燃料。", Overview: "你擅长把模糊目标拆成路径,讨厌无效社交与反复改口。成长点是:在推进前多留一句对人的确认,让方案更容易被接纳。", Strengths: []string{"战略感强", "独立推进", "标准清晰"}, Watchouts: []string{"显得疏离", "对低效不耐烦", "过度封闭计划"}, Tips: []string{"关键节点先同步再独断", "给情绪留 10 分钟再决策", "用清单外的「弹性项」练习放手"}, Scripts: []string{"我的建议是……,你最担心哪一步?", "我想先对齐目标,再谈细节可以吗?"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次决策前先问对方感受"}, {"phase": "本月", "focus": "公开一个半成品计划收集反馈"}}}
case "INTP":
return pack{Label: label, Summary: "你靠好奇与逻辑拆解世界,喜欢把概念想透再行动。", Overview: "分析是你的舒适区,拖延常来自还想再想清楚。成长点是设定「够好就提交」的截止点,把洞见变成可验证的小实验。", Strengths: []string{"抽象思考", "问题拆解", "开放好奇"}, Watchouts: []string{"迟迟不落地", "忽略关系节奏", "过度纠结定义"}, Tips: []string{"每个想法配一个 48 小时小实验", "讨论时先复述对方一句", "用番茄钟切分析与执行"}, Scripts: []string{"我还在梳理,今晚给你一个阶段性结论。", "我理解你的点;我补充一个角度……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "完成一个未收尾的小输出"}, {"phase": "本月", "focus": "建立「思考→验证」双清单"}}}
case "ENTJ":
return pack{Label: label, Summary: "你天然想把事情推动到结果,目标感强、节奏快。", Overview: "你适合带队攻坚,但速度可能压到他人感受。成长点是:在下达目标时同步「为什么」与「需要的支持」。", Strengths: []string{"决策果断", "组织推进", "结果导向"}, Watchouts: []string{"控制欲过强", "忽视情绪成本", "把慢当成错"}, Tips: []string{"每周一次只听不评判的同步", "目标拆成共同里程碑", "表扬过程不只结果"}, Scripts: []string{"我们要达成……,你卡在哪里我可以支援?", "我的时间表是……,你的约束是什么?"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次会议先听完再给方案"}, {"phase": "本月", "focus": "把一个目标改成共创版"}}}
case "ENTP":
return pack{Label: label, Summary: "你爱挑战既有假设,点子多、辩论感强,讨厌无聊重复。", Overview: "灵感是你的超能力,落地与收尾是课题。成长点是选一个点子陪它走完最小闭环。", Strengths: []string{"快速联想", "打破僵局", "说服力"}, Watchouts: []string{"虎头蛇尾", "为辩而辩", "承诺过多"}, Tips: []string{"新点子先写「不做清单」", "找一个落地搭档", "争论前先确认共同目标"}, Scripts: []string{"换个角度看:如果……会怎样?", "我可以先做一版原型,我们再决定。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "只推进一个点子到可演示"}, {"phase": "本月", "focus": "练习结束争论的收束句"}}}
case "INFJ":
return pack{Label: label, Summary: "你敏感于意义与人心走向,常在安静处看见别人没说出口的事。", Overview: "理想驱动你,也容易负荷过重。成长点是把关怀加上边界:不是所有情绪都要你接住。", Strengths: []string{"洞察力", "长期愿景", "深度共情"}, Watchouts: []string{"过度负责", "理想落差挫败", "表达拐弯"}, Tips: []string{"每天留无负担独处", "重要请求直接说", "区分「关心」与「承包」"}, Scripts: []string{"我感受到你……,我能做的是……,但我需要……", "这件事对我意义很大,我想认真谈一次。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "拒绝一次超出负荷的请求"}, {"phase": "本月", "focus": "把一个愿景写成三步小行动"}}}
case "INFP":
return pack{Label: label, Summary: "你按内在价值行动,重视真实与温柔,讨厌被强迫表演。", Overview: "当环境违背价值观你会撤退或内耗。成长点是把「我在意什么」说清楚,让他人有机会配合你。", Strengths: []string{"价值感强", "创造力", "真诚"}, Watchouts: []string{"逃避冲突", "理想化他人", "自我怀疑"}, Tips: []string{"冲突时先写三句再谈", "每周做一件对齐价值的小事", "接受「足够好」的交付"}, Scripts: []string{"这件事触及我很在意的……,我们可以一起找折中吗?", "我需要一点时间整理感受,晚点认真回复你。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次温和但清楚的表达"}, {"phase": "本月", "focus": "建立个人价值清单并对照日程"}}}
case "ENFJ":
return pack{Label: label, Summary: "你擅长点燃群体、照顾节奏,常成为大家的「主心骨」。", Overview: "你容易把别人的成长扛在肩上。成长点是学会把责任还回去,并照顾自己的能量账户。", Strengths: []string{"召集力", "鼓励他人", "氛围敏感"}, Watchouts: []string{"自我耗竭", "讨好倾向", "过度介入"}, Tips: []string{"帮助前先问「你需要什么」", "每周固定自我补给", "把表扬与界限一起说"}, Scripts: []string{"我很想支持你;这次我能做到的是……", "我们一起定个节奏,好让彼此都不透支。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次帮助改为赋能提问"}, {"phase": "本月", "focus": "建立个人恢复仪式"}}}
case "ENFP":
return pack{Label: label, Summary: "你热情、好奇、连接人与可能性,讨厌被框死。", Overview: "开始很容易,专注收尾较难。成长点是给热情加一点结构:选少做深。", Strengths: []string{"感染力", "创意联想", "关系活力"}, Watchouts: []string{"分心多线", "承诺膨胀", "情绪起伏大"}, Tips: []string{"同时只养 2 个重点项目", "用伙伴盯收尾", "低能量日允许缩小社交圈"}, Scripts: []string{"我超有感觉!我们先定最小一步……", "今天我状态一般,改天再深聊可以吗?"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "砍掉一个分心事项"}, {"phase": "本月", "focus": "完成一个从灵感到交付的闭环"}}}
case "ISTJ":
return pack{Label: label, Summary: "你可靠、重承诺,喜欢把规则与流程做清楚。", Overview: "稳定是你的礼物,变化可能让你紧绷。成长点是在原则内留一个「可谈判区」。", Strengths: []string{"执行力", "细节准确", "责任感"}, Watchouts: []string{"固执流程", "不善表达情感", "对新法抵触"}, Tips: []string{"变更前先写利弊表", "主动说一句关心", "把例外规则写进清单"}, Scripts: []string{"按计划我们该……;若要改,影响是……", "我在意可靠,所以想先确认时间点。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "接受一次小范围变更"}, {"phase": "本月", "focus": "练习情绪用词清单"}}}
case "ISFJ":
return pack{Label: label, Summary: "你细心照顾日常与人情,默默把安全感建起来。", Overview: "你常先顾别人。成长点是让需要被看见:被照顾也是关系的一部分。", Strengths: []string{"体贴", "记忆细节", "稳定支持"}, Watchouts: []string{"压抑需求", "怕冲突", "过度付出"}, Tips: []string{"每周提出一个真实偏好", "把付出写成可轮换的任务", "疲惫时直接说停"}, Scripts: []string{"我一直在帮忙……,这次我也需要……", "我有点累了,今晚想安静一下。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次主动提出需要"}, {"phase": "本月", "focus": "建立付出与回收的平衡表"}}}
case "ESTJ":
return pack{Label: label, Summary: "你讲效率、抓标准,擅长把混乱整理成秩序。", Overview: "你推动系统运转,也可能被看成强硬。成长点是解释标准背后的公平意图。", Strengths: []string{"组织力", "决断", "落地快"}, Watchouts: []string{"命令口吻", "忽视个别情况", "急于纠正"}, Tips: []string{"指令后加一句「你怎么看」", "例外审批透明化", "表扬合规与创新并重"}, Scripts: []string{"标准是为了……,特殊情况我们可以……", "先按这个做一版,再一起复盘。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次决策征求异议"}, {"phase": "本月", "focus": "写清团队共同规则"}}}
case "ESFJ":
return pack{Label: label, Summary: "你重视和谐与归属,善于把人连接成互相照顾的圈。", Overview: "和谐重要,但回避真实分歧会累积委屈。成长点是温和地谈不同意。", Strengths: []string{"协调", "热心", "仪式感"}, Watchouts: []string{"过度在意评价", "讨好", "压抑不满"}, Tips: []string{"分歧用「我观察/我需要」句式", "减少即时回消息压力", "为自己保留无角色时间"}, Scripts: []string{"我很在乎我们关系,所以想说一下我的不适……", "大家开心很重要,我也需要……被考虑。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次诚实但不伤人的反馈"}, {"phase": "本月", "focus": "减少一次为和谐的妥协"}}}
case "ISTP":
return pack{Label: label, Summary: "你冷静拆解问题,动手能力强,讨厌空洞说教。", Overview: "你用行动证明理解。成长点是在关系里多给一点过程说明,避免被当成冷淡。", Strengths: []string{"实操", "危机冷静", "独立"}, Watchouts: []string{"话少被误解", "回避情绪话题", "承诺随意"}, Tips: []string{"行动前后各说一句意图", "定期短同步代替长会议", "练习命名当下感受"}, Scripts: []string{"我去处理……,大概多久回来。", "我不一定能立刻共情,但我可以帮忙做……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "三次行动前口头同步"}, {"phase": "本月", "focus": "一次情绪向长谈不逃开"}}}
case "ISFP":
return pack{Label: label, Summary: "你活在感受与美感里,用体验表达自己,讨厌被强迫。", Overview: "你需要空间与节奏。成长点是在被压时及时说「停」,而不是默默消失。", Strengths: []string{"审美敏感", "温和", "当下投入"}, Watchouts: []string{"逃避压力", "需求不清", "突然抽离"}, Tips: []string{"压力信号出现就说", "用创作或散步调节", "重要约定写成双方可见"}, Scripts: []string{"我现在有点满,需要安静恢复一下。", "这件事的感觉对我很重要,我们慢慢谈。"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次及时表达边界"}, {"phase": "本月", "focus": "建立个人恢复清单"}}}
case "ESTP":
return pack{Label: label, Summary: "你抓当下机会、行动快,喜欢真刀真枪地试。", Overview: "冲劲带来结果,也可能低估后果。成长点是重大行动前留 10 分钟风险评估。", Strengths: []string{"应变", "胆识", "现实感"}, Watchouts: []string{"冲动", "忽略长远", "听不进劝"}, Tips: []string{"大决定用「最坏情况」三问", "找一个踩刹车的伙伴", "运动发泄代替硬刚冲突"}, Scripts: []string{"我想先试一版,失败了我们立刻收。", "你提醒的风险我记下了,我的底线是……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "一次冲动决定延后 24 小时"}, {"phase": "本月", "focus": "建立行动前检查卡"}}}
case "ESFP":
return pack{Label: label, Summary: "你把活力带给现场,重视体验、分享与即时快乐。", Overview: "你点亮气氛,也可能回避沉闷的深度议题。成长点是留下处理难聊话题的固定时段。", Strengths: []string{"感染力", "体贴当下", "乐观"}, Watchouts: []string{"回避严肃议题", "过度取悦", "财务/计划松散"}, Tips: []string{"每周一次「认真聊」预约", "快乐预算设上限", "用日历保护恢复日"}, Scripts: []string{"今天先开心,明天我们认真谈……可以吗?", "我很想让大家轻松,但这件事我也有压力……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "完成一次被拖延的认真对话"}, {"phase": "本月", "focus": "建立简单收支或计划习惯"}}}
default:
return pack{Label: label, Summary: "你的四维偏好组合较均衡或尚未充分显现主导面。", Overview: "可把本次结果当作起点,过一段时间在不同情境再测,观察哪一维更稳定。", Strengths: []string{"灵活", "可塑", "情境适应"}, Watchouts: []string{"自我描述模糊", "随大流", "缺少稳定策略"}, Tips: []string{"记录一周高能/耗能场景", "对争议维多做情景题自问", "与信任的人对照描述"}, Scripts: []string{"我还在认识自己,目前更接近……", "在……情境里我更像……"}, GrowthPlan: []map[string]string{{"phase": "本周", "focus": "日记标注能量来源"}, {"phase": "本月", "focus": "复测并对比变化"}}}
}
}
+32
View File
@@ -32,11 +32,43 @@ func resultPack(slug, key, fallbackLabel string) pack {
switch slug {
case "emotion-pattern":
return emotionPack(key, fallbackLabel)
case "mbti-lite":
return mbtiPack(key, fallbackLabel)
default:
if key == "high" || key == "mid" || key == "low" {
return bankExplorePack(fallbackLabel)
}
return communicationPack(key, fallbackLabel)
}
}
func bankExplorePack(label string) pack {
if label == "" {
label = "节奏适中型"
}
return pack{
Label: label, ShareLine: "我的探索结果:" + label,
Summary: "这是基于题库作答的自我探索速览,用于觉察倾向与日常参考,不是能力定论,更不是心理或医学诊断。",
Overview: "题库结果会随作答波动。你可以把它当成一面镜子:哪些选项更容易选中、身体与情绪有什么反应,比「分数高低」更重要。",
Dimensions: []map[string]interface{}{
{"title": "自我觉察", "score": 72, "note": "留意答题时的身体感受"},
{"title": "可行动性", "score": 68, "note": "挑一条小习惯试一周"},
{"title": "非定论", "score": 90, "note": "结果可随阶段变化"},
},
Strengths: []string{"愿意自我观察", "愿意花时间完成题库"},
Watchouts: []string{"不要把结果当标签钉死", "不适情绪请寻求专业支持"},
Tips: []string{"截图保存一句对你有感的描述", "把一条建议写成今日小行动", "可与问答助手聊聊具体情境"},
Scripts: []string{"这份结果让我想到……", "我想先观察一周再决定要不要改习惯。"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "选一条建议做一次小实验"},
{"phase": "本月", "focus": "复盘:哪些描述仍然贴切"},
},
FAQ: []map[string]string{
{"q": "这是心理诊断吗?", "a": "不是。愈心谷题库只做自我探索参考,不能替代专业评估或治疗。"},
},
}
}
func communicationPack(key, fallback string) pack {
switch key {
case "A":
+42
View File
@@ -40,3 +40,45 @@ func EmotionLabels() map[string]string {
"C": "行动调节型",
}
}
// ScoreMBTI tallies E/I · S/N · T/F · J/P and returns a 4-letter type code + Chinese label.
func ScoreMBTI(answers map[string]string) (typeCode, label string) {
count := func(a, b string) (na, nb int) {
for _, v := range answers {
switch v {
case a:
na++
case b:
nb++
}
}
return
}
pick := func(a, b string, na, nb int) string {
if nb > na {
return b
}
return a
}
e, i := count("E", "I")
s, n := count("S", "N")
t, f := count("T", "F")
j, p := count("J", "P")
code := pick("E", "I", e, i) + pick("S", "N", s, n) + pick("T", "F", t, f) + pick("J", "P", j, p)
return code, MBTILabel(code)
}
// MBTILabel maps 4-letter exploration code to a lexicon-safe Chinese label.
func MBTILabel(code string) string {
if l, ok := mbtiLabels[code]; ok {
return l
}
return "平衡探索型"
}
var mbtiLabels = map[string]string{
"INTJ": "战略建构者", "INTP": "概念探路者", "ENTJ": "目标推动者", "ENTP": "灵感挑战者",
"INFJ": "愿景洞察者", "INFP": "价值守护者", "ENFJ": "共鸣召集者", "ENFP": "热情启发者",
"ISTJ": "稳健执行者", "ISFJ": "细致守护者", "ESTJ": "秩序统筹者", "ESFJ": "温暖协调者",
"ISTP": "务实拆解者", "ISFP": "感受体验者", "ESTP": "当下行动者", "ESFP": "活力分享者",
}
+28 -1
View File
@@ -1,6 +1,9 @@
package scale
import "testing"
import (
"strconv"
"testing"
)
func TestScoreMajority_communication(t *testing.T) {
key, label := ScoreMajority(map[string]string{
@@ -20,6 +23,30 @@ func TestScoreMajority_emotion(t *testing.T) {
}
}
func TestScoreJungian_ENFP(t *testing.T) {
// 8 Qs per dim: EI low→E, SN high→N, TF low→F, JP high→P
meta := map[string]JungianQuestionMeta{}
answers := map[string]string{}
add := func(dim string, ids []string, vals []int) {
for i, id := range ids {
meta[id] = JungianQuestionMeta{Dimension: dim}
answers[id] = strconv.Itoa(vals[i])
}
}
add("EI", []string{"e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8"}, []int{1, 1, 2, 1, 2, 1, 1, 2}) // sum 11 → E
add("SN", []string{"s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"}, []int{5, 5, 4, 5, 4, 5, 5, 4}) // sum 37 → N
add("TF", []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"}, []int{1, 2, 1, 2, 1, 1, 2, 1}) // sum 11 → F
add("JP", []string{"j1", "j2", "j3", "j4", "j5", "j6", "j7", "j8"}, []int{5, 4, 5, 5, 4, 5, 5, 4}) // sum 37 → P
code, _, pct := ScoreJungian(answers, meta, 8)
if code != "ENFP" {
t.Fatalf("code=%s want ENFP", code)
}
r := BuildJungianResult(code, pct)
if r["type_code"] != "ENFP" {
t.Fatalf("type_code=%v", r["type_code"])
}
}
func TestScoreMajority_empty(t *testing.T) {
_, label := ScoreMajority(nil, CommunicationLabels(), "平衡探索型")
if label != "平衡探索型" {
+266
View File
@@ -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
}
}
+34
View File
@@ -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
@@ -46,6 +46,15 @@ var allowedNames = map[string]struct{}{
"synastry_invite_created": {}, "synastry_invite_accepted": {}, "synastry_nearby_opened": {},
"star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {},
"cards_scene_selected": {}, "cards_drawn": {}, "cards_quota_exhausted": {},
"star_completed": {}, "rhythm_completed": {},
"auth_register": {}, "auth_login": {}, "auth_logout": {},
"avatar_sheet_opened": {}, "avatar_upload_succeeded": {}, "avatar_upload_failed": {},
"nickname_updated": {},
"scale_list_viewed": {}, "scale_bank_category_viewed": {},
"scale_started": {}, "scale_completed": {}, "scale_result_reopened": {},
"scale_retake_clicked": {}, "scale_locked_viewed": {}, "scale_share_clicked": {},
"scale_cta_relation": {}, "scale_cta_ask": {},
"growth_plan_viewed": {}, "growth_plan_created": {}, "growth_plan_checkin": {},
}
var allowedPropKeys = map[string]struct{}{
@@ -53,9 +62,11 @@ var allowedPropKeys = map[string]struct{}{
"element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {},
"source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {},
"depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {},
"slug": {}, "category": {}, "reason": {}, "access": {},
}
var funnelDefault = []string{
"scale_list_viewed", "scale_started", "scale_completed",
"portrait_completed", "deep_access_clicked", "purchase_completed",
}
+14 -13
View File
@@ -15,6 +15,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
// FreeQuota is the number of assistant replies allowed without membership.
@@ -42,7 +43,11 @@ func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateT
return nil, errors.New("profile not found")
}
var scene *string
if sc := strings.TrimSpace(in.Scene); sc != "" {
if strings.TrimSpace(in.Scene) != "" {
sc, err := textsafe.Check(textsafe.Scene, in.Scene)
if err != nil {
return nil, err
}
scene = &sc
}
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
@@ -137,12 +142,10 @@ type SendResult struct {
// SendMessage stores user content, consumes quota, generates assistant reply.
func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("content required")
}
if len([]rune(content)) > 2000 {
return nil, errors.New("content too long")
var err error
content, err = textsafe.Check(textsafe.AskContent, content)
if err != nil {
return nil, err
}
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
@@ -281,12 +284,10 @@ func (s *Service) StreamMessage(ctx context.Context, userID, threadID uuid.UUID,
if emit == nil {
return errors.New("emit required")
}
content = strings.TrimSpace(content)
if content == "" {
return errors.New("content required")
}
if len([]rune(content)) > 2000 {
return errors.New("content too long")
var err error
content, err = textsafe.Check(textsafe.AskContent, content)
if err != nil {
return err
}
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
+57 -14
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
"mime/multipart"
"strings"
"time"
@@ -12,21 +13,25 @@ import (
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
// Service handles register/login sessions.
// Temporary open mode: any non-empty phone+password can enter; missing accounts are created.
type Service struct {
Repo *repository.AuthRepo
Repo *repository.AuthRepo
AvatarDir string
}
// Me is the public account payload.
type Me struct {
ID string `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
ID string `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// SessionResult is returned after register/login.
@@ -34,6 +39,7 @@ type SessionResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User Me `json:"user"`
IsNew bool `json:"is_new"`
}
// Register upgrades or opens an account (same open rules as Login).
@@ -52,7 +58,14 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
if phone == "" {
return nil, errors.New("请填写手机号")
}
nickIn = nickgen.Normalize(nickIn)
nickIn = strings.TrimSpace(nickIn)
if nickIn != "" {
n, err := textsafe.Check(textsafe.Nickname, nickIn)
if err != nil {
return nil, err
}
nickIn = n
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
@@ -73,7 +86,7 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
nick = nickgen.Random()
_ = s.Repo.UpdateNickname(ctx, acc.ID, nick)
}
return s.issue(ctx, acc.ID, acc.Phone, nick)
return s.issue(ctx, acc.ID, acc.Phone, nick, false)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
@@ -99,18 +112,23 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, uid)
}
return s.issue(ctx, uid, phone, nick)
return s.issue(ctx, uid, phone, nick, true)
}
// UpdateNickname changes account display nickname (116 chars).
func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick string) (*Me, error) {
nick = nickgen.Normalize(nick)
if nick == "" {
return nil, errors.New("请填写昵称")
nick, err := textsafe.Check(textsafe.Nickname, nick)
if err != nil {
return nil, err
}
if err := s.Repo.UpdateNickname(ctx, userID, nick); err != nil {
return nil, err
}
return s.Me(ctx, userID)
}
// UpdateAvatar stores profile photo and returns updated me.
func (s *Service) UpdateAvatar(ctx context.Context, userID uuid.UUID, fh *multipart.FileHeader) (*Me, error) {
acc, err := s.Repo.GetAccount(ctx, userID)
if err != nil {
return nil, err
@@ -118,7 +136,18 @@ func (s *Service) UpdateNickname(ctx context.Context, userID uuid.UUID, nick str
if acc.Phone == "" {
return nil, errors.New("未登录")
}
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
dir := s.AvatarDir
if dir == "" {
dir = "data/avatars"
}
path, err := avatar.Store(dir, userID, fh)
if err != nil {
return nil, err
}
if err := s.Repo.UpdateAvatarURL(ctx, userID, path); err != nil {
return nil, err
}
return s.Me(ctx, userID)
}
// Logout revokes the current bearer session and unbinds the device from the account
@@ -141,7 +170,7 @@ func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
if acc.Phone == "" {
return nil, errors.New("未登录")
}
return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
return toMe(acc), nil
}
// ResolveSessionUser returns user id for a live token.
@@ -154,18 +183,32 @@ func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, err
return s.Repo.IsRegistered(ctx, userID)
}
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string) (*SessionResult, error) {
func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool) (*SessionResult, error) {
tok := "usr_" + randomHex(24)
exp := time.Now().Add(30 * 24 * time.Hour)
if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil {
return nil, err
}
me := &Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname}
if acc, err := s.Repo.GetAccount(ctx, userID); err == nil {
me = toMe(acc)
}
return &SessionResult{
Token: tok, ExpiresAt: exp,
User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
User: *me,
IsNew: isNew,
}, nil
}
func toMe(acc *repository.AccountRow) *Me {
return &Me{
ID: acc.ID.String(),
Phone: maskPhone(acc.Phone),
Nickname: acc.Nickname,
AvatarURL: acc.AvatarURL,
}
}
func maskPhone(p string) string {
if len(p) != 11 {
return p
@@ -67,8 +67,10 @@ func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profil
log.Printf("bootstrap portrait: %v", err)
}
now := time.Now().In(time.FixedZone("CST", 8*3600))
outS, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
AsOf: now,
})
if err != nil {
log.Printf("bootstrap star: %v", err)
@@ -80,7 +82,7 @@ func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profil
}
}
outR := rhythm.Build(p.BirthDate, p.DisplayName)
outR := rhythm.BuildWith(p.BirthDate, p.DisplayName, now)
sum, _ = json.Marshal(outR.Summary)
det, _ = json.Marshal(outR.Detail)
if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil {
@@ -3,7 +3,6 @@ package companion
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
@@ -12,6 +11,7 @@ import (
core "github.com/yuxingu/digital-psychology/apps/api/internal/companion"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
// Service exposes solar terms and moods.
@@ -37,11 +37,15 @@ func (s *Service) SaveMood(ctx context.Context, userID uuid.UUID, in SaveMoodInp
return nil, errors.New("score must be 1-5")
}
if in.Note != nil {
n := strings.TrimSpace(*in.Note)
if len([]rune(n)) > 200 {
return nil, errors.New("note too long")
n, err := textsafe.Check(textsafe.Note, *in.Note)
if err != nil {
return nil, err
}
if n == "" {
in.Note = nil
} else {
in.Note = &n
}
in.Note = &n
}
day := time.Now()
if in.Day != nil {
+150 -39
View File
@@ -14,6 +14,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/yijing"
)
@@ -33,58 +34,131 @@ type DailyTips struct {
Source string `json:"source"` // llm | fallback
GuaName string `json:"gua_name,omitempty"`
DayPart string `json:"day_part,omitempty"`
AsOf string `json:"as_of"` // YYYY-MM-DD
Shichen int `json:"shichen"`
ShichenName string `json:"shichen_name"`
ValidUntil string `json:"valid_until"` // RFC3339 next 时辰 start
NeedBirth bool `json:"need_birth"`
}
type tipsCacheEntry struct {
type tipsMemEntry struct {
tips DailyTips
exp time.Time
}
var tipsCache sync.Map // key string → tipsCacheEntry
var (
tipsMem sync.Map // cacheKey → tipsMemEntry
tipsInflight sync.Map // cacheKey → *sync.Mutex generating
)
// DailyTips returns personalized tips; falls back when LLM / profile unavailable.
func tipsCacheKey(userID uuid.UUID, start time.Time) string {
return userID.String() + ":" + start.UTC().Format(time.RFC3339)
}
// DailyTips returns tips for the current 时辰; prefers cache, never blocks on LLM.
func (s *Service) DailyTips(ctx context.Context, userID uuid.UUID) (*DailyTips, error) {
now := time.Now()
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.FixedZone("CST", 8*3600)
}
now = now.In(loc)
win := CurrentShichen(now)
asOf := now.Format("2006-01-02")
self, ok := s.findSelf(ctx, userID)
if !ok {
fb := fallbackTips(now, "", nil)
fb.NeedBirth = true
if !ok || userID == uuid.Nil {
fb := decorateTips(fallbackTips(now, "", nil), win, asOf, true)
return fb, nil
}
birth := self.BirthDate.Format("2006-01-02")
yc := yijing.Seed(birth, self.BirthTime, now)
cacheKey := fmt.Sprintf("%s:%s:%s:%d", userID, birth, now.Format("2006-01-02"), now.Hour()/3)
if v, hit := tipsCache.Load(cacheKey); hit {
e := v.(tipsCacheEntry)
if time.Now().Before(e.exp) {
out := e.tips
return &out, nil
key := tipsCacheKey(userID, win.Start)
if v, hit := tipsMem.Load(key); hit {
out := v.(tipsMemEntry).tips
return decorateTips(&out, win, asOf, false), nil
}
if s.Tips != nil {
if raw, source, err := s.Tips.GetTips(ctx, userID, win.Start); err == nil {
var t DailyTips
if json.Unmarshal(raw, &t) == nil {
t.Source = source
tipsMem.Store(key, tipsMemEntry{tips: t})
return decorateTips(&t, win, asOf, false), nil
}
} else if err != repository.ErrNoTips {
log.Printf("home daily-tips db: %v", err)
}
}
if s.LLM == nil || !s.LLM.Enabled() {
fb := fallbackTips(now, birth, self.BirthTime)
fb.GuaName = yc.GuaName
fb.DayPart = yc.DayPart
return fb, nil
}
// Instant path: deterministic fallback, warm LLM in background.
fb := fallbackTips(now, birth, self.BirthTime)
out := decorateTips(fb, win, asOf, false)
tipsMem.Store(key, tipsMemEntry{tips: *out})
s.persistTips(userID, win, out)
s.warmLLMAsync(userID, birth, self.BirthTime, win, asOf, key)
return out, nil
}
tips, err := s.generateLLMTips(ctx, yc)
if err != nil {
log.Printf("home daily-tips llm: %v", err)
fb := fallbackTips(now, birth, self.BirthTime)
fb.GuaName = yc.GuaName
fb.DayPart = yc.DayPart
return fb, nil
func decorateTips(t *DailyTips, win ShichenWindow, asOf string, needBirth bool) *DailyTips {
cp := *t
cp.AsOf = asOf
cp.Shichen = win.Index
cp.ShichenName = win.Name + "时"
cp.ValidUntil = win.End.Format(time.RFC3339)
cp.NeedBirth = needBirth
return &cp
}
func (s *Service) persistTips(userID uuid.UUID, win ShichenWindow, tips *DailyTips) {
if s.Tips == nil || tips == nil {
return
}
tips.Source = "llm"
tips.GuaName = yc.GuaName
tips.DayPart = yc.DayPart
tips.NeedBirth = false
tipsCache.Store(cacheKey, tipsCacheEntry{tips: *tips, exp: now.Add(45 * time.Minute)})
return tips, nil
raw, err := json.Marshal(tips)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.Tips.UpsertTips(ctx, userID, win.Start, win.Index, raw, tips.Source); err != nil {
log.Printf("home daily-tips upsert: %v", err)
}
}
func (s *Service) warmLLMAsync(userID uuid.UUID, birth string, birthTime *string, win ShichenWindow, asOf, key string) {
if s.LLM == nil || !s.LLM.Enabled() {
return
}
if _, loaded := tipsInflight.LoadOrStore(key, true); loaded {
return
}
go func() {
defer tipsInflight.Delete(key)
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
now := win.Start.Add(30 * time.Minute) // representative instant inside 时辰
if now.After(win.End) {
now = win.Start
}
yc := yijing.Seed(birth, birthTime, now)
tips, err := s.generateLLMTips(ctx, yc, now, win)
if err != nil {
log.Printf("home daily-tips llm warm: %v", err)
return
}
tips.Source = "llm"
tips.ClothingIndex = clothingIndexFromSeed(yc, now, win.Index)
tips.GuaName = yc.GuaName
tips.DayPart = yc.DayPart
out := decorateTips(tips, win, asOf, false)
tipsMem.Store(key, tipsMemEntry{tips: *out})
s.persistTips(userID, win, out)
}()
}
func clothingIndexFromSeed(yc yijing.Context, now time.Time, shichen int) int {
n := yc.GuaIndex*11 + yc.Line*5 + now.YearDay()*3 + shichen*17
return 55 + n%41
}
func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profile, bool) {
@@ -103,17 +177,22 @@ func (s *Service) findSelf(ctx context.Context, userID uuid.UUID) (*model.Profil
return nil, false
}
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context) (*DailyTips, error) {
sys := `你是愈心谷的生活节律助手根据用户出生信息与当前时刻的易经卦象种子给出温和的生活建议
func (s *Service) generateLLMTips(ctx context.Context, yc yijing.Context, now time.Time, win ShichenWindow) (*DailyTips, error) {
weekday := []string{"日", "一", "二", "三", "四", "五", "六"}[now.Weekday()]
sys := `你是愈心谷的生活节律助手根据用户出生信息与当前时辰的易经卦象种子给出温和的生活建议
硬性要求
- 禁止占卜/算命/改命恐吓/吉凶祸福话术
- 禁止医疗疗效承诺
- 用探索向身心节律语气可参考卦象意象不要说必有类断言
- 文案必须贴合此刻时辰与日期体现与上一时辰不同的节律侧重
- 只输出 JSON不要 markdown
{"clothing_index":70,"clothing":"一句穿衣建议","color_note":"一句颜色说明","palette":[{"name":"色名","hex":"#RRGGBB"},{"name":"色名","hex":"#RRGGBB"}],"wellness":"一句养生建议"}
- clothing_index 5595 整数palette 恰好 2 文案各不超过 40 `
user := yc.PromptLine() + "\n请生成今日穿衣指数、颜色搭配与养生推荐。"
user := fmt.Sprintf(
"%s\n今天是 %s(星期%s),当前%s时。请生成贴合本时辰的穿衣指数、颜色搭配与养生推荐,勿与通用套话雷同。",
yc.PromptLine(), now.Format("2006-01-02"), weekday, win.Name,
)
raw, err := s.LLM.Chat(ctx, []deepseek.Message{
{Role: "system", Content: sys},
{Role: "user", Content: user},
@@ -181,12 +260,20 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
if birth == "" {
yc = yijing.Seed(now.Format("2006-01-02"), nil, now)
}
win := CurrentShichen(now)
palettes := [][]ColorChip{
{{Name: "米白", Hex: "#F5F0E8"}, {Name: "雾霾蓝", Hex: "#A8C4D8"}},
{{Name: "燕麦色", Hex: "#E8DCC8"}, {Name: "浅杏", Hex: "#F0C9A8"}},
{{Name: "浅灰", Hex: "#D8D6D4"}, {Name: "雾粉", Hex: "#E8B8C4"}},
{{Name: "象牙白", Hex: "#F7F4EC"}, {Name: "鼠尾草", Hex: "#A8C4B0"}},
{{Name: "浅卡其", Hex: "#DCC8A8"}, {Name: "浅蓝", Hex: "#B0D0E8"}},
{{Name: "天青", Hex: "#9BB8D4"}, {Name: "陶土", Hex: "#C4A484"}},
{{Name: "豆沙", Hex: "#C9A0A0"}, {Name: "雾绿", Hex: "#B5C9B8"}},
{{Name: "奶油", Hex: "#F3EADF"}, {Name: "烟灰", Hex: "#B8B4B0"}},
{{Name: "浅咖", Hex: "#C8B09A"}, {Name: "靛蓝", Hex: "#6B8CAE"}},
{{Name: "杏白", Hex: "#F6EBDD"}, {Name: "藕荷", Hex: "#D4B8C4"}},
{{Name: "竹青", Hex: "#A8BFA8"}, {Name: "沙色", Hex: "#E0D0B8"}},
{{Name: "月白", Hex: "#EEF2F5"}, {Name: "焦糖", Hex: "#C4A070"}},
}
clothing := []string{
"轻薄透气更舒服,外套可备一件薄衫应付温差。",
@@ -194,18 +281,41 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
"上下同色系更省心,再加一件小配饰点睛即可。",
"宽松剪裁更自在,适合慢节奏出门与散步。",
"内里干净简约,外搭一件有质感的外套就够。",
"今日宜层搭:薄内搭+透气外衫,方便随时增减。",
"选垂感面料更显松弛,走路也会轻一点。",
"少用厚重配饰,让肩颈更轻松。",
"浅色上衣更提气色,下装选舒适脚感即可。",
"风大时加一件防风薄外套,比厚羽绒服更灵活。",
"本时辰适合干净线条,少印花更省心。",
"运动鞋或软底鞋更友好,站久也不累。",
}
notes := []string{
"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。",
"安静又有呼吸感。", "干净利落,好搭配。", "此刻偏清透,层次更轻。",
"暖调一点点,气色更稳。", "冷暖各一,对比柔和。", "低饱和更耐看。",
"偏自然色,贴近户外光线。", "雾感配色,不刺眼。", "柔对比,适合慢节奏。",
}
notes := []string{"清爽干净,不抢戏。", "温柔耐看,适合日常。", "柔和提气色,不显沉。", "安静又有呼吸感。", "干净利落,好搭配。"}
well := []string{
"午后泡一杯温茶,给身心一点缓冲。",
"今晚早点放下屏幕,让眼睛歇一会儿。",
"走路时把肩膀放松,呼吸会顺很多。",
"饭后慢走十分钟,比猛坐着更舒服。",
"喝水提醒自己小口多次,别等口渴再灌。",
"此刻做三次深呼吸,拉长呼气更易放松。",
"站起来伸个懒腰,活动一下髋与肩。",
"晚饭少一点刺激,给肠胃留空档。",
"睡前把明天三件小事写下来,心会静些。",
"听一首慢歌,跟着节奏把呼吸放慢。",
"把窗帘开一条缝,让自然光进一点。",
"洗手洗脸用温水,给皮肤一点温柔。",
}
n := len(palettes)
i := (yc.GuaIndex*13 + yc.Line*7 + now.YearDay()*3 + win.Index*19) % n
if i < 0 {
i = -i
}
i := (yc.GuaIndex + yc.Line + now.Hour()) % 5
return &DailyTips{
ClothingIndex: 62 + (yc.GuaIndex % 31),
ClothingIndex: clothingIndexFromSeed(yc, now, win.Index),
Clothing: clothing[i],
ColorNote: notes[i],
Palette: palettes[i],
@@ -213,5 +323,6 @@ func fallbackTips(now time.Time, birth string, birthTime *string) *DailyTips {
Source: "fallback",
GuaName: yc.GuaName,
DayPart: yc.DayPart,
AsOf: now.Format("2006-01-02"),
}
}
@@ -29,6 +29,7 @@ var allowedIcons = map[string]struct{}{
// Service serves homepage tool catalog and daily tips.
type Service struct {
Repo *repository.HomeToolsRepo
Tips *repository.HomeDailyTipsRepo
Profiles *repository.ProfileRepo
LLM *deepseek.Client
}
+54 -1
View File
@@ -1,6 +1,9 @@
package home
import "testing"
import (
"testing"
"time"
)
func TestNormalizeToolOK(t *testing.T) {
b := "热"
@@ -28,3 +31,53 @@ func TestNormalizeToolRejects(t *testing.T) {
}
}
}
func TestFallbackTipsChangeByDay(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
d1 := time.Date(2026, 8, 11, 19, 0, 0, 0, loc)
d2 := time.Date(2026, 8, 12, 19, 0, 0, 0, loc)
a := fallbackTips(d1, "1990-05-12", nil)
b := fallbackTips(d2, "1990-05-12", nil)
if a.ClothingIndex == b.ClothingIndex && a.Clothing == b.Clothing && a.Wellness == b.Wellness {
t.Fatalf("expected day-varying tips, got same %+v", a)
}
if a.AsOf == b.AsOf {
t.Fatalf("as_of should differ: %s", a.AsOf)
}
}
func TestCurrentShichenBoundaries(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
cases := []struct {
h, wantIdx int
wantName string
}{
{23, 0, "子"},
{0, 0, "子"},
{9, 5, "巳"},
{10, 5, "巳"},
{11, 6, "午"},
}
for _, c := range cases {
now := time.Date(2026, 8, 12, c.h, 15, 0, 0, loc)
w := CurrentShichen(now)
if w.Index != c.wantIdx || w.Name != c.wantName {
t.Fatalf("h=%d got %d %s want %d %s", c.h, w.Index, w.Name, c.wantIdx, c.wantName)
}
if !w.Start.Before(now) && !w.Start.Equal(now) {
t.Fatalf("start should be <= now: %v %v", w.Start, now)
}
if !w.End.After(now) {
t.Fatalf("end should be > now")
}
}
}
func TestFallbackTipsChangeByShichen(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
a := fallbackTips(time.Date(2026, 8, 12, 9, 0, 0, 0, loc), "1990-05-12", nil)
b := fallbackTips(time.Date(2026, 8, 12, 11, 0, 0, 0, loc), "1990-05-12", nil)
if a.Clothing == b.Clothing && a.Wellness == b.Wellness && a.ClothingIndex == b.ClothingIndex {
t.Fatalf("expected shichen-varying tips")
}
}
+32
View File
@@ -0,0 +1,32 @@
package home
import (
"time"
)
var shichenNames = [12]string{"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"}
// ShichenWindow is the current Chinese double-hour window.
type ShichenWindow struct {
Index int
Name string
Start time.Time
End time.Time
}
// CurrentShichen returns the 十二时辰 window for now (子时 23:0001:00 …).
func CurrentShichen(now time.Time) ShichenWindow {
h := now.Hour()
idx := ((h + 1) % 24) / 2
startHour := (idx*2 + 23) % 24
start := time.Date(now.Year(), now.Month(), now.Day(), startHour, 0, 0, 0, now.Location())
if start.After(now) {
start = start.Add(-24 * time.Hour)
}
return ShichenWindow{
Index: idx,
Name: shichenNames[idx],
Start: start,
End: start.Add(2 * time.Hour),
}
}
@@ -76,3 +76,12 @@ func (s *Service) Get(ctx context.Context, userID uuid.UUID) (*Me, error) {
AskQuotaLeft: row.AskQuotaLeft,
}, nil
}
// IsActive reports whether the user has an active成长会员.
func (s *Service) IsActive(ctx context.Context, userID uuid.UUID) (bool, error) {
me, err := s.Get(ctx, userID)
if err != nil {
return false, err
}
return me.Active, nil
}
@@ -11,6 +11,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
// Service manages personal archives.
@@ -58,6 +59,10 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
name = "TA"
}
}
name, err := textsafe.Check(textsafe.DisplayName, name)
if err != nil {
return nil, err
}
p, err := s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
if err != nil {
return nil, err
@@ -94,6 +99,12 @@ func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in Up
name := strings.TrimSpace(in.DisplayName)
if name == "" {
name = cur.DisplayName
} else {
var err error
name, err = textsafe.Check(textsafe.DisplayName, name)
if err != nil {
return nil, err
}
}
birth := in.BirthDate
if birth.IsZero() {
+11 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
// Service creates and reads growth reports with entitlement trimming.
@@ -98,6 +99,10 @@ func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token,
if name == "" {
name = "TA"
}
name, err = textsafe.Check(textsafe.DisplayName, name)
if err != nil {
return nil, err
}
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
if err != nil {
return nil, errors.New("host profile missing")
@@ -147,6 +152,7 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
}
out, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
AsOf: time.Now().In(time.FixedZone("CST", 8*3600)),
})
if err != nil {
return nil, err
@@ -212,7 +218,7 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
if err != nil {
return nil, errors.New("profile not found")
}
out := rhythm.Build(p.BirthDate, p.DisplayName)
out := rhythm.BuildWith(p.BirthDate, p.DisplayName, time.Now().In(time.FixedZone("CST", 8*3600)))
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
@@ -222,21 +228,23 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
return s.applyEntitlement(ctx, userID, rep)
}
// GetLatest returns cached report by profile+type(+peer).
// GetLatest returns cached report by profile+type(+peer); refreshes star/rhythm day tips.
func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
if err != nil {
return nil, errors.New("report not found")
}
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
return s.applyEntitlement(ctx, userID, rep)
}
// Get returns a report with detail gated.
// Get returns a report with detail gated; refreshes star/rhythm day tips.
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
if err != nil {
return nil, errors.New("report not found")
}
rep, _ = s.refreshTemporalIfNeeded(ctx, userID, rep)
return s.applyEntitlement(ctx, userID, rep)
}
@@ -0,0 +1,127 @@
package report
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
)
func cstLoc() *time.Location {
return time.FixedZone("CST", 8*3600)
}
func dayKey(t time.Time) string {
return t.In(cstLoc()).Format("2006-01-02")
}
func summaryDay(raw json.RawMessage) string {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
s, _ := m["as_of"].(string)
return s
}
func hasWuxingBars(raw json.RawMessage) bool {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return false
}
wx, _ := m["wuxing"].(map[string]any)
if wx == nil {
return false
}
bars, ok := wx["bars"].([]any)
return ok && len(bars) > 0
}
func rhythmNeedsContentBackfill(raw json.RawMessage) bool {
if !hasWuxingBars(raw) {
return true
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return true
}
prev, _ := m["blind_spots_preview"].([]any)
if len(prev) == 0 {
return true
}
for _, item := range prev {
s, _ := item.(string)
if s == "完整习惯方案见深度版。" || strings.Contains(s, "见深度版") {
return true
}
}
return false
}
// refreshTemporalIfNeeded updates star/rhythm day-scoped tips in place when as_of is stale.
func (s *Service) refreshTemporalIfNeeded(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) {
if rep == nil || (rep.Type != "star" && rep.Type != "rhythm") {
return rep, nil
}
now := time.Now().In(cstLoc())
today := dayKey(now)
staleDay := summaryDay(rep.Summary) != today
needBackfill := rep.Type == "rhythm" && rhythmNeedsContentBackfill(rep.Summary)
if !staleDay && !needBackfill {
return stampValidUntil(rep, now), nil
}
p, err := s.Profiles.GetForUser(ctx, userID, rep.ProfileID)
if err != nil {
return rep, nil
}
var sum, det json.RawMessage
switch rep.Type {
case "star":
out, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace,
Name: p.DisplayName, AsOf: now,
})
if err != nil {
return rep, nil
}
sum, _ = json.Marshal(out.Summary)
det, _ = json.Marshal(out.Detail)
case "rhythm":
out := rhythm.BuildWith(p.BirthDate, p.DisplayName, now)
sum, _ = json.Marshal(out.Summary)
det, _ = json.Marshal(out.Detail)
default:
return rep, nil
}
if err := s.Reports.UpdateContent(ctx, userID, rep.ID, sum, det); err != nil {
return rep, nil
}
rep.Summary = sum
rep.Detail = det
return rep, nil
}
func stampValidUntil(rep *model.GrowthReport, now time.Time) *model.GrowthReport {
var m map[string]any
if err := json.Unmarshal(rep.Summary, &m); err != nil {
return rep
}
if _, ok := m["valid_until"].(string); ok && m["as_of"] == dayKey(now) {
return rep
}
m["as_of"] = dayKey(now)
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
m["valid_until"] = next.Format(time.RFC3339)
raw, err := json.Marshal(m)
if err != nil {
return rep
}
rep.Summary = raw
return rep
}
@@ -0,0 +1,69 @@
package report
import (
"encoding/json"
"testing"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
)
func TestDayKeyCST(t *testing.T) {
// 2026-08-12 23:30 UTC = 2026-08-13 07:30 CST
utc := time.Date(2026, 8, 12, 23, 30, 0, 0, time.UTC)
if got := dayKey(utc); got != "2026-08-13" {
t.Fatalf("dayKey=%s", got)
}
}
func TestStarOutlookChangesByDay(t *testing.T) {
birth := time.Date(1990, 5, 15, 0, 0, 0, 0, time.UTC)
d1 := time.Date(2026, 8, 12, 10, 0, 0, 0, cstLoc())
d2 := time.Date(2026, 8, 13, 10, 0, 0, 0, cstLoc())
a, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d1})
if err != nil {
t.Fatal(err)
}
b, err := star.BuildWith(star.BuildOpts{Birth: birth, Name: "测", AsOf: d2})
if err != nil {
t.Fatal(err)
}
if a.Summary["as_of"] != "2026-08-12" || b.Summary["as_of"] != "2026-08-13" {
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
}
oa, _ := a.Summary["outlook"].(map[string]any)
ob, _ := b.Summary["outlook"].(map[string]any)
da, _ := oa["daily"].(map[string]any)
db, _ := ob["daily"].(map[string]any)
if da["score"] == db["score"] && da["tip"] == db["tip"] {
t.Fatalf("expected daily outlook to differ across days")
}
if a.Summary["valid_until"] == nil || b.Summary["valid_until"] == nil {
t.Fatal("missing valid_until")
}
}
func TestRhythmTipsChangeByDay(t *testing.T) {
birth := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
d1 := time.Date(2026, 8, 10, 12, 0, 0, 0, cstLoc()) // Mon
d2 := time.Date(2026, 8, 11, 12, 0, 0, 0, cstLoc()) // Tue
a := rhythm.BuildWith(birth, "测", d1)
b := rhythm.BuildWith(birth, "测", d2)
if a.Summary["as_of"] != "2026-08-10" || b.Summary["as_of"] != "2026-08-11" {
t.Fatalf("as_of a=%v b=%v", a.Summary["as_of"], b.Summary["as_of"])
}
if a.Summary["week_focus"] == b.Summary["week_focus"] && a.Summary["today_tip"] == b.Summary["today_tip"] {
t.Fatalf("expected tips to differ")
}
}
func TestSummaryDay(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"as_of": "2026-08-12"})
if summaryDay(raw) != "2026-08-12" {
t.Fatal(summaryDay(raw))
}
if summaryDay(json.RawMessage(`{}`)) != "" {
t.Fatal("empty")
}
}
+124 -14
View File
@@ -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)
}
+7 -1
View File
@@ -66,11 +66,15 @@ func BuildWith(opts BuildOpts) (Output, error) {
asOf := opts.AsOf
if asOf.IsZero() {
asOf = time.Now()
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
} else {
asOf = asOf.In(time.FixedZone("CST", 8*3600))
}
fort := outlook.Build(chart, asOf)
daily := fort.Daily
fortMap := fort.AsMap()
dayKey := asOf.Format("2006-01-02")
validUntil := time.Date(asOf.Year(), asOf.Month(), asOf.Day()+1, 0, 0, 0, 0, asOf.Location())
planetsOut := make([]map[string]any, 0, len(chart.Planets))
for _, p := range chart.Planets {
@@ -121,6 +125,8 @@ func BuildWith(opts BuildOpts) (Output, error) {
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
"energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label,
},
"as_of": dayKey,
"valid_until": validUntil.Format(time.RFC3339),
"strengths_preview": pack.Strengths[:min(3, len(pack.Strengths))],
"blind_spots_preview": []string{"完整相位与年运详解见深度版。"},
"interaction_tags": []string{
+184
View File
@@ -0,0 +1,184 @@
// Package textsafe validates user free-text at the API boundary (Spec input-compliance).
package textsafe
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// Kind selects length limits and whether empty is allowed.
type Kind int
const (
Nickname Kind = iota
DisplayName
AskContent
Note // optional
Title
Focus // optional
Scene
)
// ErrRejected is returned for compliance failures (map to HTTP 400 / code 40060).
var ErrRejected = errors.New("文案不合规")
type limits struct {
min, max int
allowNL bool
}
func lim(k Kind) limits {
switch k {
case Nickname:
return limits{1, 16, false}
case DisplayName:
return limits{1, 24, false}
case AskContent:
return limits{1, 2000, true}
case Note:
return limits{0, 200, true}
case Title:
return limits{1, 40, false}
case Focus:
return limits{0, 40, false}
case Scene:
return limits{1, 80, false}
default:
return limits{1, 200, false}
}
}
var banned = []string{
"占卜", "算命", "改命", "测测",
"疗效", "包治", "根治", "治疗疾病", "治愈癌症",
"不测就有灾", "大师算卦", "神棍",
}
// Check normalizes and validates. Empty optional kinds return ("", nil).
func Check(kind Kind, raw string) (string, error) {
s := Normalize(raw, lim(kind).allowNL)
l := lim(kind)
n := utf8.RuneCountInString(s)
if n < l.min {
if l.min == 0 {
return "", nil
}
return "", reject("请填写内容")
}
if n > l.max {
return "", reject("内容过长")
}
if err := rejectControls(s, l.allowNL); err != nil {
return "", err
}
if err := rejectMarkup(s); err != nil {
return "", err
}
if err := rejectBanned(s); err != nil {
return "", err
}
if err := rejectSpam(s); err != nil {
return "", err
}
return s, nil
}
// Normalize trims; optionally keeps internal newlines.
func Normalize(s string, allowNL bool) string {
s = strings.TrimSpace(s)
if !allowNL {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
}
return s
}
func reject(msg string) error {
return fmt.Errorf("%w%s", ErrRejected, msg)
}
func rejectControls(s string, allowNL bool) error {
for _, r := range s {
if r == 0 {
return reject("含非法字符")
}
if r == '\n' || r == '\r' || r == '\t' {
if !allowNL {
return reject("含非法空白")
}
continue
}
if r < 0x20 || r == 0x7f {
return reject("含非法字符")
}
}
return nil
}
func rejectMarkup(s string) error {
low := strings.ToLower(s)
if strings.Contains(low, "<script") || strings.Contains(low, "javascript:") {
return reject("含不安全内容")
}
if strings.Contains(low, "onerror=") || strings.Contains(low, "onload=") {
return reject("含不安全内容")
}
// bare tags like <img ...>
for i := 0; i < len(s); i++ {
if s[i] != '<' {
continue
}
if i+1 < len(s) {
c := s[i+1]
if c == '/' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '!' {
return reject("含不安全内容")
}
}
}
return nil
}
func rejectBanned(s string) error {
for _, b := range banned {
if b != "" && strings.Contains(s, b) {
return reject("含不允许的表述,请换一种说法")
}
}
return nil
}
func rejectSpam(s string) error {
runes := []rune(s)
if len(runes) >= 6 {
allSame := true
for i := 1; i < len(runes); i++ {
if runes[i] != runes[0] {
allSame = false
break
}
}
if allSame && !unicode.IsSpace(runes[0]) {
return reject("请勿重复刷屏")
}
}
streak := 1
for i := 1; i < len(runes); i++ {
if runes[i] == runes[i-1] && !unicode.IsSpace(runes[i]) {
streak++
if streak >= 8 {
return reject("请勿重复刷屏")
}
} else {
streak = 1
}
}
return nil
}
@@ -0,0 +1,54 @@
package textsafe_test
import (
"strings"
"testing"
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
)
func TestOK(t *testing.T) {
got, err := textsafe.Check(textsafe.Nickname, " 心语岛 ")
if err != nil || got != "心语岛" {
t.Fatalf("got %q err=%v", got, err)
}
got, err = textsafe.Check(textsafe.DisplayName, "小愈")
if err != nil || got != "小愈" {
t.Fatalf("got %q err=%v", got, err)
}
}
func TestBanned(t *testing.T) {
if _, err := textsafe.Check(textsafe.Nickname, "算命达人"); err == nil {
t.Fatal("expected reject")
}
if _, err := textsafe.Check(textsafe.AskContent, "帮我占卜一下"); err == nil {
t.Fatal("expected reject")
}
}
func TestXSS(t *testing.T) {
if _, err := textsafe.Check(textsafe.AskContent, `hi <script>alert(1)</script>`); err == nil {
t.Fatal("expected reject")
}
}
func TestSpam(t *testing.T) {
if _, err := textsafe.Check(textsafe.Note, "啊啊啊啊啊啊啊啊"); err == nil {
t.Fatal("expected reject")
}
}
func TestLength(t *testing.T) {
long := strings.Repeat("字", 20)
if _, err := textsafe.Check(textsafe.Nickname, long); err == nil {
t.Fatal("expected reject")
}
}
func TestOptionalEmpty(t *testing.T) {
got, err := textsafe.Check(textsafe.Note, " ")
if err != nil || got != "" {
t.Fatalf("got %q err=%v", got, err)
}
}
+7 -7
View File
@@ -18,12 +18,12 @@ func TestSeedStableSameInputs(t *testing.T) {
}
}
func TestSeedChangesWithHour(t *testing.T) {
now1 := time.Date(2026, 8, 11, 9, 0, 0, 0, time.FixedZone("CST", 8*3600))
now2 := time.Date(2026, 8, 11, 18, 0, 0, 0, time.FixedZone("CST", 8*3600))
a := Seed("1990-05-12", nil, now1)
b := Seed("1990-05-12", nil, now2)
if a.DayPart == b.DayPart {
t.Fatalf("expected different day parts, both %s", a.DayPart)
func TestSeedChangesWithDay(t *testing.T) {
d1 := time.Date(2026, 8, 11, 19, 0, 0, 0, time.FixedZone("CST", 8*3600))
d2 := time.Date(2026, 8, 12, 19, 0, 0, 0, time.FixedZone("CST", 8*3600))
a := Seed("1990-05-12", nil, d1)
b := Seed("1990-05-12", nil, d2)
if a.GuaIndex == b.GuaIndex && a.Line == b.Line {
t.Fatalf("expected seed to change across days, both gua=%d line=%d", a.GuaIndex, a.Line)
}
}
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
@@ -0,0 +1,2 @@
-- ECR avatar: account profile photo URL
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url varchar(512) NULL;
@@ -0,0 +1,9 @@
UPDATE home_tools
SET label = '人格测试', updated_at = now()
WHERE path = '/scales/mbti-lite' AND icon = 'mbti';
UPDATE scales
SET title = '人格类型探索',
description = '了解能量来源与决策偏好的轻量探索。',
updated_at = now()
WHERE slug = 'mbti-lite' AND deleted_at IS NULL;
@@ -0,0 +1,11 @@
-- Rename mbti-lite display to MBTI测试 (ECR-adjacent · explore-test R9)
UPDATE scales
SET title = 'MBTI测试',
description = '了解能量来源与决策偏好的轻量探索。',
updated_at = now()
WHERE slug = 'mbti-lite' AND deleted_at IS NULL;
UPDATE home_tools
SET label = 'MBTI测试', updated_at = now()
WHERE path = '/scales/mbti-lite' AND icon = 'mbti';
@@ -0,0 +1,20 @@
-- Revert mbti-lite to previous 3-question lite set (best-effort)
UPDATE scale_questions
SET deleted_at = now(), updated_at = now()
WHERE scale_id = '22222222-2222-2222-2222-222222222201'
AND deleted_at IS NULL;
UPDATE scales
SET title = 'MBTI测试',
description = '了解能量来源与决策偏好的轻量探索。',
updated_at = now()
WHERE id = '22222222-2222-2222-2222-222222222201';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222201', 1,
'{"prompt":"周末充电,你更想?","options":[{"key":"E","text":"和朋友见面聊天"},{"key":"I","text":"独处安静恢复"}]}'),
('22222222-2222-2222-2222-222222222201', 2,
'{"prompt":"做决定时你更看重?","options":[{"key":"T","text":"逻辑与公平"},{"key":"F","text":"感受与关系"}]}'),
('22222222-2222-2222-2222-222222222201', 3,
'{"prompt":"面对计划,你更倾向?","options":[{"key":"J","text":"提前安排清楚"},{"key":"P","text":"保持弹性随机"}]}');
@@ -0,0 +1,47 @@
-- MBTI测试:替换极简 3 题为四维 12 题;旧结果失效(explore-test R10/R11
UPDATE scale_questions
SET deleted_at = now(), updated_at = now()
WHERE scale_id = '22222222-2222-2222-2222-222222222201'
AND deleted_at IS NULL;
UPDATE scale_results
SET deleted_at = now(), updated_at = now()
WHERE scale_id = '22222222-2222-2222-2222-222222222201'
AND deleted_at IS NULL;
UPDATE scales
SET title = 'MBTI测试',
description = '四维偏好探索(能量 · 信息 · 决策 · 节奏),约 5 分钟。结果用于自我了解,不是固定标签。',
updated_at = now()
WHERE id = '22222222-2222-2222-2222-222222222201';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
-- E / I
('22222222-2222-2222-2222-222222222201', 1,
'{"prompt":"参加不太熟的聚会时,你更常?","options":[{"key":"E","text":"主动找人聊天、很快融入"},{"key":"I","text":"先观察一会儿,和少数人深聊"}]}'),
('22222222-2222-2222-2222-222222222201', 2,
'{"prompt":"连续社交一天后,你更想?","options":[{"key":"E","text":"再约人聊聊,延续热闹"},{"key":"I","text":"回家独处,安静恢复"}]}'),
('22222222-2222-2222-2222-222222222201', 3,
'{"prompt":"想清楚一件难事时,你更习惯?","options":[{"key":"E","text":"边说边想,找人讨论"},{"key":"I","text":"先自己想透,再开口"}]}'),
-- S / N
('22222222-2222-2222-2222-222222222201', 4,
'{"prompt":"听别人讲经历,你更抓住?","options":[{"key":"S","text":"具体细节和实际经过"},{"key":"N","text":"背后的含义与可能趋势"}]}'),
('22222222-2222-2222-2222-222222222201', 5,
'{"prompt":"学新技能时,你更喜欢?","options":[{"key":"S","text":"按步骤练熟,先把基础做扎实"},{"key":"N","text":"先看整体框架,再联想用法"}]}'),
('22222222-2222-2222-2222-222222222201', 6,
'{"prompt":"讨论未来计划,你更容易?","options":[{"key":"S","text":"关注眼下可执行的安排"},{"key":"N","text":"先想象多种可能性与愿景"}]}'),
-- T / F
('22222222-2222-2222-2222-222222222201', 7,
'{"prompt":"朋友来倾诉冲突,你更先?","options":[{"key":"T","text":"帮对方理清对错与可行方案"},{"key":"F","text":"先接住情绪,让对方感到被理解"}]}'),
('22222222-2222-2222-2222-222222222201', 8,
'{"prompt":"团队做决定时,你更看重?","options":[{"key":"T","text":"标准一致、结果是否公正有效"},{"key":"F","text":"大家感受如何、关系是否受伤"}]}'),
('22222222-2222-2222-2222-222222222201', 9,
'{"prompt":"收到批评时,你内心更在意?","options":[{"key":"T","text":"对方说的是否准确、可改进"},{"key":"F","text":"语气是否尊重、关系有没有裂痕"}]}'),
-- J / P
('22222222-2222-2222-2222-222222222201', 10,
'{"prompt":"出门旅行,你更倾向?","options":[{"key":"J","text":"行程大体订好,心里踏实"},{"key":"P","text":"留白多一点,走到哪儿算哪儿"}]}'),
('22222222-2222-2222-2222-222222222201', 11,
'{"prompt":"面对截止日期,你通常?","options":[{"key":"J","text":"尽早推进,提前收尾更安心"},{"key":"P","text":"压力临近时效率最高,灵活赶完"}]}'),
('22222222-2222-2222-2222-222222222201', 12,
'{"prompt":"书桌/手机桌面,你更舒服的是?","options":[{"key":"J","text":"归类清楚,东西有固定位置"},{"key":"P","text":"随用随放,需要时再整理"}]}');
@@ -0,0 +1,10 @@
-- revert OEJTS seed (best-effort soft-delete full scale)
UPDATE scale_questions SET deleted_at = now(), updated_at = now()
WHERE scale_id IN (
'22222222-2222-2222-2222-222222222201',
'22222222-2222-2222-2222-222222222210'
) AND deleted_at IS NULL;
UPDATE scales SET deleted_at = now(), updated_at = now()
WHERE id = '22222222-2222-2222-2222-222222222210';
@@ -0,0 +1,101 @@
-- OEJTS 1.2 standard (32, free) + full (48, membership) · explore-test
-- Credit: Open Extended Jungian Type Scales / Open Psychometrics · openjung MIT
UPDATE scale_questions SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222201' AND deleted_at IS NULL;
UPDATE scale_results SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222201' AND deleted_at IS NULL;
UPDATE scales SET title='MBTI测试', description='开源 OEJTS 标准版:四维各 8 题,共 32 题。能量·信息·决策·节奏偏好探索(非官方 MBTI 版权题)。', updated_at=now() WHERE id='22222222-2222-2222-2222-222222222201';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222201', 1, '{"prompt": "你如何记录任务?", "format": "likert5", "dimension": "JP", "left": "制定清单", "right": "依靠记忆", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 2, '{"prompt": "你如何看待新信息?", "format": "likert5", "dimension": "TF", "left": "愿意相信", "right": "持怀疑态度", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 3, '{"prompt": "你对独处有什么感受?", "format": "likert5", "dimension": "EI", "left": "独处时感到无聊", "right": "需要独处时间", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 4, '{"prompt": "你如何看待现状?", "format": "likert5", "dimension": "SN", "left": "接受事物的本来面目", "right": "对现状不满足", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 5, '{"prompt": "你如何整理空间?", "format": "likert5", "dimension": "JP", "left": "保持房间整洁", "right": "随手放置东西", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 6, '{"prompt": "你如何看待逻辑思维?", "format": "likert5", "dimension": "TF", "left": "认为「像机器人」是侮辱", "right": "追求机械般的思维", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 7, '{"prompt": "你的能量状态如何?", "format": "likert5", "dimension": "EI", "left": "精力充沛", "right": "平静温和", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 8, '{"prompt": "你喜欢什么类型的考试?", "format": "likert5", "dimension": "SN", "left": "喜欢选择题", "right": "喜欢论述题", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 9, '{"prompt": "你如何形容自己的生活方式?", "format": "likert5", "dimension": "JP", "left": "有条理", "right": "随性混乱", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 10, '{"prompt": "你如何应对批评?", "format": "likert5", "dimension": "TF", "left": "容易受伤", "right": "脸皮厚", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 11, '{"prompt": "你在什么环境下工作最好?", "format": "likert5", "dimension": "EI", "left": "在团队中表现最佳", "right": "独自工作表现最佳", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 12, '{"prompt": "你的时间焦点在哪里?", "format": "likert5", "dimension": "SN", "left": "关注过去", "right": "关注未来", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 13, '{"prompt": "你何时制定计划?", "format": "likert5", "dimension": "JP", "left": "提前规划", "right": "最后一刻才计划", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 14, '{"prompt": "你希望从他人那里得到什么?", "format": "likert5", "dimension": "TF", "left": "渴望他人的爱", "right": "渴望他人的尊重", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 15, '{"prompt": "聚会让你感觉如何?", "format": "likert5", "dimension": "EI", "left": "因聚会而兴奋", "right": "因聚会而疲惫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 16, '{"prompt": "在群体中,你倾向于?", "format": "likert5", "dimension": "SN", "left": "融入群体", "right": "与众不同", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 17, '{"prompt": "你如何做决定?", "format": "likert5", "dimension": "JP", "left": "做出承诺", "right": "保留选择", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 18, '{"prompt": "你想擅长什么?", "format": "likert5", "dimension": "TF", "left": "想擅长帮助他人", "right": "想擅长修理事物", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 19, '{"prompt": "在交谈中,你更倾向于?", "format": "likert5", "dimension": "EI", "left": "话比较多", "right": "更善于倾听", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 20, '{"prompt": "讲故事时,你会?", "format": "likert5", "dimension": "SN", "left": "描述发生了什么", "right": "描述这意味着什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 21, '{"prompt": "你何时完成任务?", "format": "likert5", "dimension": "JP", "left": "立即完成工作", "right": "拖延", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 22, '{"prompt": "你做选择时更信?", "format": "likert5", "dimension": "TF", "left": "跟随内心", "right": "跟随理性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 23, '{"prompt": "周末你更喜欢做什么?", "format": "likert5", "dimension": "EI", "left": "喜欢外出活动", "right": "喜欢待在家里", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 24, '{"prompt": "学习新事物时,你想要什么?", "format": "likert5", "dimension": "SN", "left": "想要细节", "right": "想要大局观", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 25, '{"prompt": "你如何应对新情况?", "format": "likert5", "dimension": "JP", "left": "提前准备", "right": "即兴发挥", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 26, '{"prompt": "道德的基础更接近?", "format": "likert5", "dimension": "TF", "left": "道德基于同情", "right": "道德基于正义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 27, '{"prompt": "你的自然音量如何?", "format": "likert5", "dimension": "EI", "left": "自然而然大声说话", "right": "很难大声喊叫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 28, '{"prompt": "你如何获取知识?", "format": "likert5", "dimension": "SN", "left": "经验主义", "right": "理论主义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 29, '{"prompt": "什么更能驱动你?", "format": "likert5", "dimension": "JP", "left": "努力工作", "right": "尽情玩乐", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 30, '{"prompt": "你如何看待情感?", "format": "likert5", "dimension": "TF", "left": "重视情感", "right": "对情感感到不自在", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 31, '{"prompt": "你对成为焦点有什么感受?", "format": "likert5", "dimension": "EI", "left": "喜欢表演", "right": "避免公开演讲", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222201', 32, '{"prompt": "什么问题最让你感兴趣?", "format": "likert5", "dimension": "SN", "left": "想知道「谁/什么/何时」", "right": "想知道「为什么」", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb);
INSERT INTO scales (id, slug, title, description, status)
VALUES (
'22222222-2222-2222-2222-222222222210',
'mbti-full',
'MBTI完整版',
'成长会员专享:四维各 12 题,共 48 题,偏好更稳定。含 OEJTS 标准题 + 扩展题。',
'published'
) ON CONFLICT (slug) DO UPDATE SET title=EXCLUDED.title, description=EXCLUDED.description, status='published', updated_at=now(), deleted_at=NULL;
UPDATE scale_questions SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
UPDATE scale_results SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222210', 1, '{"prompt": "你如何记录任务?", "format": "likert5", "dimension": "JP", "left": "制定清单", "right": "依靠记忆", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 2, '{"prompt": "你如何看待新信息?", "format": "likert5", "dimension": "TF", "left": "愿意相信", "right": "持怀疑态度", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 3, '{"prompt": "你对独处有什么感受?", "format": "likert5", "dimension": "EI", "left": "独处时感到无聊", "right": "需要独处时间", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 4, '{"prompt": "你如何看待现状?", "format": "likert5", "dimension": "SN", "left": "接受事物的本来面目", "right": "对现状不满足", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 5, '{"prompt": "你如何整理空间?", "format": "likert5", "dimension": "JP", "left": "保持房间整洁", "right": "随手放置东西", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 6, '{"prompt": "你如何看待逻辑思维?", "format": "likert5", "dimension": "TF", "left": "认为「像机器人」是侮辱", "right": "追求机械般的思维", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 7, '{"prompt": "你的能量状态如何?", "format": "likert5", "dimension": "EI", "left": "精力充沛", "right": "平静温和", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 8, '{"prompt": "你喜欢什么类型的考试?", "format": "likert5", "dimension": "SN", "left": "喜欢选择题", "right": "喜欢论述题", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 9, '{"prompt": "你如何形容自己的生活方式?", "format": "likert5", "dimension": "JP", "left": "有条理", "right": "随性混乱", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 10, '{"prompt": "你如何应对批评?", "format": "likert5", "dimension": "TF", "left": "容易受伤", "right": "脸皮厚", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 11, '{"prompt": "你在什么环境下工作最好?", "format": "likert5", "dimension": "EI", "left": "在团队中表现最佳", "right": "独自工作表现最佳", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 12, '{"prompt": "你的时间焦点在哪里?", "format": "likert5", "dimension": "SN", "left": "关注过去", "right": "关注未来", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 13, '{"prompt": "你何时制定计划?", "format": "likert5", "dimension": "JP", "left": "提前规划", "right": "最后一刻才计划", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 14, '{"prompt": "你希望从他人那里得到什么?", "format": "likert5", "dimension": "TF", "left": "渴望他人的爱", "right": "渴望他人的尊重", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 15, '{"prompt": "聚会让你感觉如何?", "format": "likert5", "dimension": "EI", "left": "因聚会而兴奋", "right": "因聚会而疲惫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 16, '{"prompt": "在群体中,你倾向于?", "format": "likert5", "dimension": "SN", "left": "融入群体", "right": "与众不同", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 17, '{"prompt": "你如何做决定?", "format": "likert5", "dimension": "JP", "left": "做出承诺", "right": "保留选择", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 18, '{"prompt": "你想擅长什么?", "format": "likert5", "dimension": "TF", "left": "想擅长帮助他人", "right": "想擅长修理事物", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 19, '{"prompt": "在交谈中,你更倾向于?", "format": "likert5", "dimension": "EI", "left": "话比较多", "right": "更善于倾听", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 20, '{"prompt": "讲故事时,你会?", "format": "likert5", "dimension": "SN", "left": "描述发生了什么", "right": "描述这意味着什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 21, '{"prompt": "你何时完成任务?", "format": "likert5", "dimension": "JP", "left": "立即完成工作", "right": "拖延", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 22, '{"prompt": "你做选择时更信?", "format": "likert5", "dimension": "TF", "left": "跟随内心", "right": "跟随理性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 23, '{"prompt": "周末你更喜欢做什么?", "format": "likert5", "dimension": "EI", "left": "喜欢外出活动", "right": "喜欢待在家里", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 24, '{"prompt": "学习新事物时,你想要什么?", "format": "likert5", "dimension": "SN", "left": "想要细节", "right": "想要大局观", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 25, '{"prompt": "你如何应对新情况?", "format": "likert5", "dimension": "JP", "left": "提前准备", "right": "即兴发挥", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 26, '{"prompt": "道德的基础更接近?", "format": "likert5", "dimension": "TF", "left": "道德基于同情", "right": "道德基于正义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 27, '{"prompt": "你的自然音量如何?", "format": "likert5", "dimension": "EI", "left": "自然而然大声说话", "right": "很难大声喊叫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 28, '{"prompt": "你如何获取知识?", "format": "likert5", "dimension": "SN", "left": "经验主义", "right": "理论主义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 29, '{"prompt": "什么更能驱动你?", "format": "likert5", "dimension": "JP", "left": "努力工作", "right": "尽情玩乐", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 30, '{"prompt": "你如何看待情感?", "format": "likert5", "dimension": "TF", "left": "重视情感", "right": "对情感感到不自在", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 31, '{"prompt": "你对成为焦点有什么感受?", "format": "likert5", "dimension": "EI", "left": "喜欢表演", "right": "避免公开演讲", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 32, '{"prompt": "什么问题最让你感兴趣?", "format": "likert5", "dimension": "SN", "left": "想知道「谁/什么/何时」", "right": "想知道「为什么」", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 33, '{"prompt": "充电后你更想?", "format": "likert5", "dimension": "EI", "left": "继续跟人互动", "right": "继续安静独处", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 34, '{"prompt": "陌生场合开场,你更常?", "format": "likert5", "dimension": "EI", "left": "先开口搭话", "right": "等别人来找你", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 35, '{"prompt": "想法未成形时,你更习惯?", "format": "likert5", "dimension": "EI", "left": "边说边理清", "right": "想清楚再开口", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 36, '{"prompt": "长时间会议后,你通常?", "format": "likert5", "dimension": "EI", "left": "还想再聊聊", "right": "急需一个人待着", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 37, '{"prompt": "听报告时你更抓?", "format": "likert5", "dimension": "SN", "left": "数据和步骤", "right": "含义与趋势", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 38, '{"prompt": "解决问题更依赖?", "format": "likert5", "dimension": "SN", "left": "已被验证的做法", "right": "新的可能性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 39, '{"prompt": "描述一个人时你更说?", "format": "likert5", "dimension": "SN", "left": "具体言行细节", "right": "整体给人的感觉", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 40, '{"prompt": "读说明书你更喜欢?", "format": "likert5", "dimension": "SN", "left": "逐步照做", "right": "先看整体再试", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 41, '{"prompt": "同事出错时你更先?", "format": "likert5", "dimension": "TF", "left": "考虑对方感受", "right": "指出问题本身", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 42, '{"prompt": "评价方案更看?", "format": "likert5", "dimension": "TF", "left": "对人是否友好", "right": "是否高效正确", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 43, '{"prompt": "争执后你更在意?", "format": "likert5", "dimension": "TF", "left": "关系有没有裂痕", "right": "道理有没有说清", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 44, '{"prompt": "给反馈时你更倾向?", "format": "likert5", "dimension": "TF", "left": "先肯定再建议", "right": "直接说关键点", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 45, '{"prompt": "旅行行李你更?", "format": "likert5", "dimension": "JP", "left": "提前打包清单", "right": "临走再塞", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 46, '{"prompt": "工作日历你更?", "format": "likert5", "dimension": "JP", "left": "日程排满更安心", "right": "留白机动更舒服", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 47, '{"prompt": "规则变化时你?", "format": "likert5", "dimension": "JP", "left": "希望尽早确定新规", "right": "可以边走边调整", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 48, '{"prompt": "桌面文件你更?", "format": "likert5", "dimension": "JP", "left": "分类归档", "right": "先堆着再用时找", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb);
@@ -0,0 +1,2 @@
UPDATE scale_questions SET deleted_at=now(), updated_at=now()
WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL AND sort > 48;
@@ -0,0 +1,67 @@
-- Expand mbti-full to 60 questions (15 per dimension)
UPDATE scale_questions SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
UPDATE scale_results SET deleted_at=now(), updated_at=now() WHERE scale_id='22222222-2222-2222-2222-222222222210' AND deleted_at IS NULL;
UPDATE scales SET title='MBTI完整版',
description='成长会员专享:四维各 15 题,共 60 题。含 OEJTS 标准 32 题 + 扩展题,偏好更稳定。',
updated_at=now() WHERE id='22222222-2222-2222-2222-222222222210';
INSERT INTO scale_questions (scale_id, sort, body) VALUES
('22222222-2222-2222-2222-222222222210', 1, '{"prompt": "你如何记录任务?", "format": "likert5", "dimension": "JP", "left": "制定清单", "right": "依靠记忆", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 2, '{"prompt": "你如何看待新信息?", "format": "likert5", "dimension": "TF", "left": "愿意相信", "right": "持怀疑态度", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 3, '{"prompt": "你对独处有什么感受?", "format": "likert5", "dimension": "EI", "left": "独处时感到无聊", "right": "需要独处时间", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 4, '{"prompt": "你如何看待现状?", "format": "likert5", "dimension": "SN", "left": "接受事物的本来面目", "right": "对现状不满足", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 5, '{"prompt": "你如何整理空间?", "format": "likert5", "dimension": "JP", "left": "保持房间整洁", "right": "随手放置东西", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 6, '{"prompt": "你如何看待逻辑思维?", "format": "likert5", "dimension": "TF", "left": "认为「像机器人」是侮辱", "right": "追求机械般的思维", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 7, '{"prompt": "你的能量状态如何?", "format": "likert5", "dimension": "EI", "left": "精力充沛", "right": "平静温和", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 8, '{"prompt": "你喜欢什么类型的考试?", "format": "likert5", "dimension": "SN", "left": "喜欢选择题", "right": "喜欢论述题", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 9, '{"prompt": "你如何形容自己的生活方式?", "format": "likert5", "dimension": "JP", "left": "有条理", "right": "随性混乱", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 10, '{"prompt": "你如何应对批评?", "format": "likert5", "dimension": "TF", "left": "容易受伤", "right": "脸皮厚", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 11, '{"prompt": "你在什么环境下工作最好?", "format": "likert5", "dimension": "EI", "left": "在团队中表现最佳", "right": "独自工作表现最佳", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 12, '{"prompt": "你的时间焦点在哪里?", "format": "likert5", "dimension": "SN", "left": "关注过去", "right": "关注未来", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 13, '{"prompt": "你何时制定计划?", "format": "likert5", "dimension": "JP", "left": "提前规划", "right": "最后一刻才计划", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 14, '{"prompt": "你希望从他人那里得到什么?", "format": "likert5", "dimension": "TF", "left": "渴望他人的爱", "right": "渴望他人的尊重", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 15, '{"prompt": "聚会让你感觉如何?", "format": "likert5", "dimension": "EI", "left": "因聚会而兴奋", "right": "因聚会而疲惫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 16, '{"prompt": "在群体中,你倾向于?", "format": "likert5", "dimension": "SN", "left": "融入群体", "right": "与众不同", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 17, '{"prompt": "你如何做决定?", "format": "likert5", "dimension": "JP", "left": "做出承诺", "right": "保留选择", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 18, '{"prompt": "你想擅长什么?", "format": "likert5", "dimension": "TF", "left": "想擅长帮助他人", "right": "想擅长修理事物", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 19, '{"prompt": "在交谈中,你更倾向于?", "format": "likert5", "dimension": "EI", "left": "话比较多", "right": "更善于倾听", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 20, '{"prompt": "讲故事时,你会?", "format": "likert5", "dimension": "SN", "left": "描述发生了什么", "right": "描述这意味着什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 21, '{"prompt": "你何时完成任务?", "format": "likert5", "dimension": "JP", "left": "立即完成工作", "right": "拖延", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 22, '{"prompt": "你做选择时更信?", "format": "likert5", "dimension": "TF", "left": "跟随内心", "right": "跟随理性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 23, '{"prompt": "周末你更喜欢做什么?", "format": "likert5", "dimension": "EI", "left": "喜欢外出活动", "right": "喜欢待在家里", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 24, '{"prompt": "学习新事物时,你想要什么?", "format": "likert5", "dimension": "SN", "left": "想要细节", "right": "想要大局观", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 25, '{"prompt": "你如何应对新情况?", "format": "likert5", "dimension": "JP", "left": "提前准备", "right": "即兴发挥", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 26, '{"prompt": "道德的基础更接近?", "format": "likert5", "dimension": "TF", "left": "道德基于同情", "right": "道德基于正义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 27, '{"prompt": "你的自然音量如何?", "format": "likert5", "dimension": "EI", "left": "自然而然大声说话", "right": "很难大声喊叫", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 28, '{"prompt": "你如何获取知识?", "format": "likert5", "dimension": "SN", "left": "经验主义", "right": "理论主义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 29, '{"prompt": "什么更能驱动你?", "format": "likert5", "dimension": "JP", "left": "努力工作", "right": "尽情玩乐", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 30, '{"prompt": "你如何看待情感?", "format": "likert5", "dimension": "TF", "left": "重视情感", "right": "对情感感到不自在", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 31, '{"prompt": "你对成为焦点有什么感受?", "format": "likert5", "dimension": "EI", "left": "喜欢表演", "right": "避免公开演讲", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 32, '{"prompt": "什么问题最让你感兴趣?", "format": "likert5", "dimension": "SN", "left": "想知道「谁/什么/何时」", "right": "想知道「为什么」", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 33, '{"prompt": "充电后你更想?", "format": "likert5", "dimension": "EI", "left": "继续跟人互动", "right": "继续安静独处", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 34, '{"prompt": "陌生场合开场,你更常?", "format": "likert5", "dimension": "EI", "left": "先开口搭话", "right": "等别人来找你", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 35, '{"prompt": "想法未成形时,你更习惯?", "format": "likert5", "dimension": "EI", "left": "边说边理清", "right": "想清楚再开口", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 36, '{"prompt": "长时间会议后,你通常?", "format": "likert5", "dimension": "EI", "left": "还想再聊聊", "right": "急需一个人待着", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 37, '{"prompt": "学习时你更有效的是?", "format": "likert5", "dimension": "EI", "left": "小组讨论", "right": "独自钻研", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 38, '{"prompt": "电话响了你更常?", "format": "likert5", "dimension": "EI", "left": "立刻接听很自然", "right": "先看是谁再决定", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 39, '{"prompt": "庆祝好事时你更想?", "format": "likert5", "dimension": "EI", "left": "叫上朋友一起", "right": "自己慢慢回味", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 40, '{"prompt": "听报告时你更抓?", "format": "likert5", "dimension": "SN", "left": "数据和步骤", "right": "含义与趋势", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 41, '{"prompt": "解决问题更依赖?", "format": "likert5", "dimension": "SN", "left": "已被验证的做法", "right": "新的可能性", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 42, '{"prompt": "描述一个人时你更说?", "format": "likert5", "dimension": "SN", "left": "具体言行细节", "right": "整体给人的感觉", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 43, '{"prompt": "读说明书你更喜欢?", "format": "likert5", "dimension": "SN", "left": "逐步照做", "right": "先看整体再试", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 44, '{"prompt": "回忆旅行你先想起?", "format": "likert5", "dimension": "SN", "left": "吃了什么、走了哪", "right": "当时的气氛与意义", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 45, '{"prompt": "面对新工具你更?", "format": "likert5", "dimension": "SN", "left": "先摸清每个按钮", "right": "先想它能改变什么", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 46, '{"prompt": "听建议时你更信?", "format": "likert5", "dimension": "SN", "left": "有案例与证据的", "right": "有洞见与方向的", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 47, '{"prompt": "同事出错时你更先?", "format": "likert5", "dimension": "TF", "left": "考虑对方感受", "right": "指出问题本身", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 48, '{"prompt": "评价方案更看?", "format": "likert5", "dimension": "TF", "left": "对人是否友好", "right": "是否高效正确", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 49, '{"prompt": "争执后你更在意?", "format": "likert5", "dimension": "TF", "left": "关系有没有裂痕", "right": "道理有没有说清", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 50, '{"prompt": "给反馈时你更倾向?", "format": "likert5", "dimension": "TF", "left": "先肯定再建议", "right": "直接说关键点", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 51, '{"prompt": "看电影你更容易?", "format": "likert5", "dimension": "TF", "left": "代入角色情绪", "right": "分析结构与逻辑", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 52, '{"prompt": "团队冲突你更想?", "format": "likert5", "dimension": "TF", "left": "先缓和气氛", "right": "先对齐标准", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 53, '{"prompt": "做艰难决定时你更问?", "format": "likert5", "dimension": "TF", "left": "谁会受伤", "right": "哪边更合理", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 54, '{"prompt": "旅行行李你更?", "format": "likert5", "dimension": "JP", "left": "提前打包清单", "right": "临走再塞", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 55, '{"prompt": "工作日历你更?", "format": "likert5", "dimension": "JP", "left": "日程排满更安心", "right": "留白机动更舒服", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 56, '{"prompt": "规则变化时你?", "format": "likert5", "dimension": "JP", "left": "希望尽早确定新规", "right": "可以边走边调整", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 57, '{"prompt": "桌面文件你更?", "format": "likert5", "dimension": "JP", "left": "分类归档", "right": "先堆着再用时找", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 58, '{"prompt": "周末安排你更?", "format": "likert5", "dimension": "JP", "left": "大致几点做什么", "right": "看当天心情", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 59, '{"prompt": "项目收尾你更?", "format": "likert5", "dimension": "JP", "left": "尽早关掉待办", "right": "一直改到不得不停", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb),
('22222222-2222-2222-2222-222222222210', 60, '{"prompt": "约会迟到风险时你?", "format": "likert5", "dimension": "JP", "left": "宁可早到等待", "right": "卡点出发也行", "options": [{"key": "1", "text": "非常接近左侧"}, {"key": "2", "text": "比较接近左侧"}, {"key": "3", "text": "居中"}, {"key": "4", "text": "比较接近右侧"}, {"key": "5", "text": "非常接近右侧"}]}'::jsonb);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS home_daily_tips;
@@ -0,0 +1,14 @@
-- Persist homepage tips per user × 时辰 (ECR-adjacent · home R7)
CREATE TABLE IF NOT EXISTS home_daily_tips (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
shichen_start timestamptz NOT NULL,
shichen smallint NOT NULL CHECK (shichen BETWEEN 0 AND 11),
tips jsonb NOT NULL,
source varchar(16) NOT NULL DEFAULT 'fallback',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, shichen_start)
);
CREATE INDEX IF NOT EXISTS idx_home_daily_tips_user_start
ON home_daily_tips (user_id, shichen_start DESC);