From 15a9db374a2e12397c239ba39520d3036c0c6363 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Sun, 2 Aug 2026 16:29:31 +0800 Subject: [PATCH] feat: add exploration scale list, questions, and scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed communication-style scale, expose /api/v1/scales APIs, and wire Explore/Scale H5 pages for the P1 探索测试 path. Co-authored-by: Cursor --- apps/api/cmd/server/main.go | 3 + apps/api/internal/handler/scale.go | 74 ++++++++++++++ apps/api/internal/repository/scale_repo.go | 110 +++++++++++++++++++++ apps/api/internal/service/scale/service.go | 86 ++++++++++++++++ apps/api/migrations/000003_scales.down.sql | 3 + apps/api/migrations/000003_scales.up.sql | 58 +++++++++++ apps/user-h5/src/pages/ExplorePage.vue | 32 +++++- apps/user-h5/src/pages/ScalePage.vue | 102 +++++++++++++++++++ apps/user-h5/src/router/index.ts | 1 + packages/sdk/src/index.ts | 14 +++ 10 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 apps/api/internal/handler/scale.go create mode 100644 apps/api/internal/repository/scale_repo.go create mode 100644 apps/api/internal/service/scale/service.go create mode 100644 apps/api/migrations/000003_scales.down.sql create mode 100644 apps/api/migrations/000003_scales.up.sql create mode 100644 apps/user-h5/src/pages/ScalePage.vue diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 95854f2..6685e8f 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "github.com/yuxingu/digital-psychology/apps/api/internal/service/profile" "github.com/yuxingu/digital-psychology/apps/api/internal/service/relation" "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/scale" "github.com/yuxingu/digital-psychology/apps/api/pkg/response" ) @@ -54,6 +55,7 @@ func main() { profileSvc := &profile.Service{Repo: profileRepo} reportSvc := &report.Service{Profiles: profileRepo, Reports: reportRepo} relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo} + scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo} r := gin.New() r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID()) @@ -73,6 +75,7 @@ func main() { (&handler.ProfileHandler{Svc: profileSvc}).Register(authed) (&handler.ReportHandler{Svc: reportSvc}).Register(authed) (&handler.RelationHandler{Svc: relationSvc}).Register(authed) + (&handler.ScaleHandler{Svc: scaleSvc}).Register(authed) log.Printf("yuxingu api listening on %s env=%s", cfg.HTTPAddr, cfg.AppEnv) if err := r.Run(cfg.HTTPAddr); err != nil { diff --git a/apps/api/internal/handler/scale.go b/apps/api/internal/handler/scale.go new file mode 100644 index 0000000..15ff862 --- /dev/null +++ b/apps/api/internal/handler/scale.go @@ -0,0 +1,74 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/scale" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// ScaleHandler exposes 探索测试 APIs. +type ScaleHandler struct { + Svc *scale.Service +} + +// Register mounts routes. +func (h *ScaleHandler) Register(rg *gin.RouterGroup) { + rg.GET("/scales", h.List) + rg.GET("/scales/:slug", h.Get) + rg.POST("/scales/:slug/result", h.Submit) +} + +// List handles GET /scales. +func (h *ScaleHandler) List(c *gin.Context) { + items, err := h.Svc.List(c.Request.Context()) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50003, "list failed") + return + } + response.OK(c, gin.H{"items": items}) +} + +// Get handles GET /scales/:slug. +func (h *ScaleHandler) Get(c *gin.Context) { + d, err := h.Svc.Get(c.Request.Context(), c.Param("slug")) + if err != nil { + response.Fail(c, http.StatusNotFound, 40402, err.Error()) + return + } + response.OK(c, d) +} + +// Submit handles POST /scales/:slug/result. +func (h *ScaleHandler) Submit(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + ProfileID string `json:"profile_id" binding:"required"` + Answers map[string]string `json:"answers" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + pid, err := uuid.Parse(req.ProfileID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id") + return + } + out, err := h.Svc.Submit(c.Request.Context(), userID, c.Param("slug"), scale.SubmitInput{ + ProfileID: pid, Answers: req.Answers, + }) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30006, err.Error()) + return + } + response.OK(c, out) +} diff --git a/apps/api/internal/repository/scale_repo.go b/apps/api/internal/repository/scale_repo.go new file mode 100644 index 0000000..07052b3 --- /dev/null +++ b/apps/api/internal/repository/scale_repo.go @@ -0,0 +1,110 @@ +package repository + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ScaleListItem is a published 探索测试. +type ScaleListItem struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` +} + +// ScaleQuestion is one item in a scale. +type ScaleQuestion struct { + ID uuid.UUID `json:"id"` + Sort int `json:"sort"` + Body json.RawMessage `json:"body"` +} + +// ScaleDetail is scale + questions. +type ScaleDetail struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + Questions []ScaleQuestion `json:"questions"` +} + +// ScaleResultRow stored result. +type ScaleResultRow struct { + ID uuid.UUID `json:"id"` + ScaleSlug string `json:"scale_slug"` + Result json.RawMessage `json:"result"` +} + +// ScaleRepo loads scales and results. +type ScaleRepo struct { + Pool *pgxpool.Pool +} + +// ListPublished returns published scales. +func (r *ScaleRepo) ListPublished(ctx context.Context) ([]ScaleListItem, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT slug, title, description FROM scales + WHERE status='published' AND deleted_at IS NULL ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ScaleListItem + for rows.Next() { + var it ScaleListItem + if err := rows.Scan(&it.Slug, &it.Title, &it.Description); err != nil { + return nil, err + } + out = append(out, it) + } + return out, rows.Err() +} + +// GetBySlug loads scale with questions. +func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, error) { + d := &ScaleDetail{Slug: slug} + var scaleID uuid.UUID + err := r.Pool.QueryRow(ctx, ` + SELECT id, title, description FROM scales + WHERE slug=$1 AND deleted_at IS NULL`, slug, + ).Scan(&scaleID, &d.Title, &d.Description) + if err != nil { + return nil, err + } + rows, err := r.Pool.Query(ctx, ` + SELECT id, sort, body FROM scale_questions + WHERE scale_id=$1 AND deleted_at IS NULL ORDER BY sort`, scaleID) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var q ScaleQuestion + if err := rows.Scan(&q.ID, &q.Sort, &q.Body); err != nil { + return nil, err + } + d.Questions = append(d.Questions, q) + } + return d, rows.Err() +} + +// SaveResult stores scoring output. +func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID uuid.UUID, answers, result json.RawMessage) (uuid.UUID, error) { + var id uuid.UUID + err := r.Pool.QueryRow(ctx, ` + INSERT INTO scale_results(user_id, scale_id, profile_id, answers, result) + VALUES ($1,$2,$3,$4,$5) RETURNING id`, + userID, scaleID, profileID, answers, result, + ).Scan(&id) + return id, err +} + +// ScaleIDBySlug resolves id. +func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) { + var id uuid.UUID + err := r.Pool.QueryRow(ctx, ` + SELECT id FROM scales WHERE slug=$1 AND deleted_at IS NULL`, slug).Scan(&id) + return id, err +} diff --git a/apps/api/internal/service/scale/service.go b/apps/api/internal/service/scale/service.go new file mode 100644 index 0000000..5e8cb36 --- /dev/null +++ b/apps/api/internal/service/scale/service.go @@ -0,0 +1,86 @@ +package scale + +import ( + "context" + "encoding/json" + "errors" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// Service serves 探索测试. +type Service struct { + Repo *repository.ScaleRepo + Profiles *repository.ProfileRepo +} + +// List returns published scales. +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) { + d, err := s.Repo.GetBySlug(ctx, slug) + if err != nil { + return nil, errors.New("scale not found") + } + return d, nil +} + +// SubmitInput answers map question_id -> option key. +type SubmitInput struct { + ProfileID uuid.UUID + Answers map[string]string +} + +// SubmitResult is exploration result. +type SubmitResult struct { + ID uuid.UUID `json:"id"` + Result map[string]interface{} `json:"result"` +} + +// Submit scores a simple majority style. +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") + } + counts := map[string]int{} + for _, v := range in.Answers { + counts[v]++ + } + best, bestN := "A", -1 + for k, n := range counts { + if n > bestN { + best, bestN = k, n + } + } + label := map[string]string{ + "A": "理性澄清型", + "B": "感受连接型", + "C": "节奏尊重型", + }[best] + if label == "" { + label = "平衡探索型" + } + result := map[string]interface{}{ + "title": "探索结果", + "style_key": best, + "label": label, + "summary": "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。", + "share_line": "我的沟通方式:" + label, + } + ansJSON, _ := json.Marshal(in.Answers) + resJSON, _ := json.Marshal(result) + id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON) + if err != nil { + return nil, err + } + return &SubmitResult{ID: id, Result: result}, nil +} diff --git a/apps/api/migrations/000003_scales.down.sql b/apps/api/migrations/000003_scales.down.sql new file mode 100644 index 0000000..6a15ee9 --- /dev/null +++ b/apps/api/migrations/000003_scales.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS scale_results; +DROP TABLE IF EXISTS scale_questions; +DROP TABLE IF EXISTS scales; diff --git a/apps/api/migrations/000003_scales.up.sql b/apps/api/migrations/000003_scales.up.sql new file mode 100644 index 0000000..c2f1d98 --- /dev/null +++ b/apps/api/migrations/000003_scales.up.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS scales ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug varchar(64) NOT NULL UNIQUE, + title varchar(128) NOT NULL, + description varchar(512) NOT NULL DEFAULT '', + status varchar(32) NOT NULL DEFAULT 'published', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); + +CREATE TABLE IF NOT EXISTS scale_questions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + scale_id uuid NOT NULL REFERENCES scales(id), + sort int NOT NULL DEFAULT 0, + body jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_scale_questions_scale_id ON scale_questions(scale_id); + +CREATE TABLE IF NOT EXISTS scale_results ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + scale_id uuid NOT NULL REFERENCES scales(id), + profile_id uuid NOT NULL REFERENCES profiles(id), + answers jsonb NOT NULL DEFAULT '{}', + result jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_scale_results_user_id ON scale_results(user_id); + +-- Seed one 探索测试 (communication style) +INSERT INTO scales (id, slug, title, description, status) +VALUES ( + '11111111-1111-1111-1111-111111111111', + 'communication-style', + '沟通方式探索', + '了解你在表达与倾听时的偏好,生成探索结果标签。', + 'published' +) ON CONFLICT (slug) DO NOTHING; + +INSERT INTO scale_questions (scale_id, sort, body) VALUES +( + '11111111-1111-1111-1111-111111111111', 1, + '{"prompt":"意见不合时,你更常怎么做?","options":[{"key":"A","text":"先讲清事实与逻辑"},{"key":"B","text":"先照顾彼此的情绪"},{"key":"C","text":"先暂停,稍后再谈"}]}' +), +( + '11111111-1111-1111-1111-111111111111', 2, + '{"prompt":"朋友找你商量事情,你更倾向于?","options":[{"key":"A","text":"帮对方拆解方案"},{"key":"B","text":"先认真听完感受"},{"key":"C","text":"给对方安静空间"}]}' +), +( + '11111111-1111-1111-1111-111111111111', 3, + '{"prompt":"你希望对方如何回应你?","options":[{"key":"A","text":"直接、清楚、可执行"},{"key":"B","text":"温暖、肯定、有共鸣"},{"key":"C","text":"尊重节奏、不急着下结论"}]}' +); diff --git a/apps/user-h5/src/pages/ExplorePage.vue b/apps/user-h5/src/pages/ExplorePage.vue index 5597430..7579b96 100644 --- a/apps/user-h5/src/pages/ExplorePage.vue +++ b/apps/user-h5/src/pages/ExplorePage.vue @@ -1,20 +1,42 @@ + + diff --git a/apps/user-h5/src/pages/ScalePage.vue b/apps/user-h5/src/pages/ScalePage.vue new file mode 100644 index 0000000..60c2870 --- /dev/null +++ b/apps/user-h5/src/pages/ScalePage.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/apps/user-h5/src/router/index.ts b/apps/user-h5/src/router/index.ts index fe6813d..4de6f5c 100644 --- a/apps/user-h5/src/router/index.ts +++ b/apps/user-h5/src/router/index.ts @@ -12,6 +12,7 @@ const router = createRouter({ { path: '/portrait', name: 'portrait', component: () => import('../pages/PortraitPage.vue'), meta: { tab: false } }, { path: '/relation', name: 'relation', component: () => import('../pages/RelationPage.vue'), meta: { tab: false } }, { path: '/membership', name: 'membership', component: () => import('../pages/MembershipPage.vue'), meta: { tab: false } }, + { path: '/scales/:slug', name: 'scale', component: () => import('../pages/ScalePage.vue'), meta: { tab: false } }, { path: '/decode', redirect: (to) => ({ path: '/portrait', query: to.query }) }, ], }) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5396301..46e48c8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -73,6 +73,20 @@ export function createClient(opts: CreateClientOptions) { method: 'POST', body: { profile_a_id, profile_b_id }, }), + listScales: () => + call<{ items: { slug: string; title: string; description: string }[] }>('/api/v1/scales'), + getScale: (slug: string) => + call<{ + slug: string + title: string + description: string + questions: { id: string; sort: number; body: { prompt: string; options: { key: string; text: string }[] } }[] + }>(`/api/v1/scales/${slug}`), + submitScale: (slug: string, profile_id: string, answers: Record) => + call<{ id: string; result: Record }>(`/api/v1/scales/${slug}/result`, { + method: 'POST', + body: { profile_id, answers }, + }), } }