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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user