feat: add RelationInsight API and wire H5 relation/profile pages
Second P1 growth engine: compare two profiles, gate full tips behind deep-access mock payment, and list personal archives on /profile. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,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/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/pkg/response"
|
||||
)
|
||||
@@ -32,7 +33,7 @@ func main() {
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Printf("database unavailable: %v", err)
|
||||
log.Printf("hint: docker compose -f deploy/docker-compose.yml up -d")
|
||||
log.Printf("hint: npm run deps:up (docker-compose.dev.yml)")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
@@ -46,11 +47,13 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
profileSvc := &profile.Service{Repo: &repository.ProfileRepo{Pool: pool}}
|
||||
reportSvc := &report.Service{
|
||||
Profiles: &repository.ProfileRepo{Pool: pool},
|
||||
Reports: &repository.ReportRepo{Pool: pool},
|
||||
}
|
||||
profileRepo := &repository.ProfileRepo{Pool: pool}
|
||||
reportRepo := &repository.ReportRepo{Pool: pool}
|
||||
relationRepo := &repository.RelationRepo{Pool: pool}
|
||||
|
||||
profileSvc := &profile.Service{Repo: profileRepo}
|
||||
reportSvc := &report.Service{Profiles: profileRepo, Reports: reportRepo}
|
||||
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
||||
@@ -69,6 +72,7 @@ func main() {
|
||||
authed.Use(middleware.DeviceAuth(pool))
|
||||
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
|
||||
(&handler.ReportHandler{Svc: reportSvc}).Register(authed)
|
||||
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
|
||||
|
||||
log.Printf("yuxingu api listening on %s env=%s", cfg.HTTPAddr, cfg.AppEnv)
|
||||
if err := r.Run(cfg.HTTPAddr); err != nil {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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/relation"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// RelationHandler exposes 关系理解 APIs.
|
||||
type RelationHandler struct {
|
||||
Svc *relation.Service
|
||||
}
|
||||
|
||||
// Register mounts routes.
|
||||
func (h *RelationHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.POST("/relation/insight", h.Create)
|
||||
}
|
||||
|
||||
// Create handles POST /relation/insight.
|
||||
func (h *RelationHandler) Create(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
ProfileAID string `json:"profile_a_id" binding:"required"`
|
||||
ProfileBID string `json:"profile_b_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
aID, err := uuid.Parse(req.ProfileAID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_a_id")
|
||||
return
|
||||
}
|
||||
bID, err := uuid.Parse(req.ProfileBID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_b_id")
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Create(c.Request.Context(), userID, aID, bID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30005, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RelationInsight is a dual-profile understanding record.
|
||||
type RelationInsight struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProfileAID uuid.UUID `json:"profile_a_id"`
|
||||
ProfileBID uuid.UUID `json:"profile_b_id"`
|
||||
Summary json.RawMessage `json:"summary"`
|
||||
ReportID *uuid.UUID `json:"report_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package relation builds 关系理解 content from two birth-based portraits.
|
||||
package relation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
|
||||
)
|
||||
|
||||
// Output is free summary + gated detail.
|
||||
type Output struct {
|
||||
Summary map[string]any
|
||||
Detail map[string]any
|
||||
}
|
||||
|
||||
// Build compares two profiles' exploration styles (lexicon-safe copy).
|
||||
func Build(aBirth, bBirth time.Time, aName, bName string) Output {
|
||||
if aName == "" {
|
||||
aName = "我"
|
||||
}
|
||||
if bName == "" {
|
||||
bName = "TA"
|
||||
}
|
||||
pa := portrait.Build(aBirth, aName)
|
||||
pb := portrait.Build(bBirth, bName)
|
||||
aLabel := str(pa.Summary["headline"])
|
||||
bLabel := str(pb.Summary["headline"])
|
||||
aKeys := strSlice(pa.Summary["keywords"])
|
||||
bKeys := strSlice(pb.Summary["keywords"])
|
||||
|
||||
summary := map[string]any{
|
||||
"title": "关系理解·基础",
|
||||
"me_style": aLabel,
|
||||
"other_style": bLabel,
|
||||
"me_keywords": aKeys,
|
||||
"other_keywords": bKeys,
|
||||
"diff_one_liner": fmt.Sprintf("%s更偏内在节奏,%s的表达方式不同——差异可以成为互补,而不是对立。", aName, bName),
|
||||
"share_hint": "了解彼此的沟通方式,查看关系理解",
|
||||
}
|
||||
detail := map[string]any{
|
||||
"title": "相处建议·完整分析",
|
||||
"communication": []string{
|
||||
fmt.Sprintf("%s:先讲清楚事实与需要,再谈感受。", aName),
|
||||
fmt.Sprintf("%s:先确认被听见,再进入方案讨论。", bName),
|
||||
"冲突时约定「复述对方一句」再回应,降低误解。",
|
||||
},
|
||||
"interaction": []string{
|
||||
"用「我观察到…我需要…」代替指责句式。",
|
||||
"每周留一次轻松同步:最近什么在消耗/滋养这段关系。",
|
||||
},
|
||||
"maintenance": []string{
|
||||
"差异标签不是评分,而是协作说明书。",
|
||||
"重要决定前,双方各写三点顾虑再合并。",
|
||||
},
|
||||
"me_behavior": str(pa.Detail["behavior_pattern"]),
|
||||
"other_behavior": str(pb.Detail["behavior_pattern"]),
|
||||
}
|
||||
return Output{Summary: summary, Detail: detail}
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func strSlice(v any) []string {
|
||||
arr, ok := v.([]string)
|
||||
if ok {
|
||||
return arr
|
||||
}
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, x := range raw {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package relation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuild(t *testing.T) {
|
||||
a := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
b := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||
out := Build(a, b, "我", "TA")
|
||||
if out.Summary["diff_one_liner"] == nil {
|
||||
t.Fatal("missing diff")
|
||||
}
|
||||
blob := str(out.Summary["diff_one_liner"]) + str(out.Detail["title"])
|
||||
for _, bad := range []string{"合盘", "运势", "吉凶", "合婚"} {
|
||||
if strings.Contains(blob, bad) {
|
||||
t.Fatalf("forbidden %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
)
|
||||
|
||||
// RelationRepo persists relation insights.
|
||||
type RelationRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Create inserts a relation insight row.
|
||||
func (r *RelationRepo) Create(ctx context.Context, userID, aID, bID uuid.UUID, summary json.RawMessage, reportID uuid.UUID) (*model.RelationInsight, error) {
|
||||
row := &model.RelationInsight{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO relation_insights(user_id, profile_a_id, profile_b_id, summary, report_id)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING id, user_id, profile_a_id, profile_b_id, summary, report_id, created_at`,
|
||||
userID, aID, bID, summary, reportID,
|
||||
).Scan(&row.ID, &row.UserID, &row.ProfileAID, &row.ProfileBID, &row.Summary, &row.ReportID, &row.CreatedAt)
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package relation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
releng "github.com/yuxingu/digital-psychology/apps/api/internal/relation"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service creates relation understanding reports.
|
||||
type Service struct {
|
||||
Profiles *repository.ProfileRepo
|
||||
Reports *repository.ReportRepo
|
||||
Relations *repository.RelationRepo
|
||||
}
|
||||
|
||||
// CreateInsightResult is API payload.
|
||||
type CreateInsightResult struct {
|
||||
Insight *model.RelationInsight `json:"insight"`
|
||||
Report *model.GrowthReport `json:"report"`
|
||||
}
|
||||
|
||||
// Create builds 关系理解 for two owned profiles.
|
||||
func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*CreateInsightResult, error) {
|
||||
if aID == bID {
|
||||
return nil, errors.New("need two different profiles")
|
||||
}
|
||||
pa, err := s.Profiles.GetForUser(ctx, userID, aID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile_a not found")
|
||||
}
|
||||
pb, err := s.Profiles.GetForUser(ctx, userID, bID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile_b not found")
|
||||
}
|
||||
out := releng.Build(pa.BirthDate, pb.BirthDate, pa.DisplayName, pb.DisplayName)
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ins, err := s.Relations.Create(ctx, userID, aID, bID, sum, rep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// entitlement trim on report
|
||||
deep, _ := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
|
||||
vip, _ := s.Reports.HasActiveMembership(ctx, userID)
|
||||
rep.HasDeep = deep || vip
|
||||
if !rep.HasDeep {
|
||||
rep.Detail = nil
|
||||
}
|
||||
return &CreateInsightResult{Insight: ins, Report: rep}, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS relation_insights;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS relation_insights (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
profile_a_id uuid NOT NULL REFERENCES profiles(id),
|
||||
profile_b_id uuid NOT NULL REFERENCES profiles(id),
|
||||
summary jsonb NOT NULL DEFAULT '{}',
|
||||
report_id uuid NULL REFERENCES growth_reports(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_insights_user_id ON relation_insights(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relation_insights_profiles ON relation_insights(profile_a_id, profile_b_id);
|
||||
Reference in New Issue
Block a user