feat: add exploration scale list, questions, and scoring

Seed communication-style scale, expose /api/v1/scales APIs, and wire
Explore/Scale H5 pages for the P1 探索测试 path.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-02 16:29:31 +08:00
co-authored by Cursor
parent 14b836f53b
commit 15a9db374a
10 changed files with 478 additions and 5 deletions
+3
View File
@@ -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 {
+74
View File
@@ -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)
}
+110
View File
@@ -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
}
@@ -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
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS scale_results;
DROP TABLE IF EXISTS scale_questions;
DROP TABLE IF EXISTS scales;
+58
View File
@@ -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":"尊重节奏、不急着下结论"}]}'
);