diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 8425c46..95854f2 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -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 { diff --git a/apps/api/internal/handler/relation.go b/apps/api/internal/handler/relation.go new file mode 100644 index 0000000..f852cfd --- /dev/null +++ b/apps/api/internal/handler/relation.go @@ -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) +} diff --git a/apps/api/internal/model/relation.go b/apps/api/internal/model/relation.go new file mode 100644 index 0000000..215916f --- /dev/null +++ b/apps/api/internal/model/relation.go @@ -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"` +} diff --git a/apps/api/internal/relation/engine.go b/apps/api/internal/relation/engine.go new file mode 100644 index 0000000..ba84b02 --- /dev/null +++ b/apps/api/internal/relation/engine.go @@ -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 +} diff --git a/apps/api/internal/relation/engine_test.go b/apps/api/internal/relation/engine_test.go new file mode 100644 index 0000000..13d4480 --- /dev/null +++ b/apps/api/internal/relation/engine_test.go @@ -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) + } + } +} diff --git a/apps/api/internal/repository/relation_repo.go b/apps/api/internal/repository/relation_repo.go new file mode 100644 index 0000000..93a66da --- /dev/null +++ b/apps/api/internal/repository/relation_repo.go @@ -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 +} diff --git a/apps/api/internal/service/relation/service.go b/apps/api/internal/service/relation/service.go new file mode 100644 index 0000000..2431414 --- /dev/null +++ b/apps/api/internal/service/relation/service.go @@ -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 +} diff --git a/apps/api/migrations/000002_relation.down.sql b/apps/api/migrations/000002_relation.down.sql new file mode 100644 index 0000000..a108a82 --- /dev/null +++ b/apps/api/migrations/000002_relation.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS relation_insights; diff --git a/apps/api/migrations/000002_relation.up.sql b/apps/api/migrations/000002_relation.up.sql new file mode 100644 index 0000000..3a87e37 --- /dev/null +++ b/apps/api/migrations/000002_relation.up.sql @@ -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); diff --git a/apps/user-h5/src/pages/ProfilePage.vue b/apps/user-h5/src/pages/ProfilePage.vue index c3e211f..e142665 100644 --- a/apps/user-h5/src/pages/ProfilePage.vue +++ b/apps/user-h5/src/pages/ProfilePage.vue @@ -3,18 +3,49 @@

个人档案

管理我的信息与重要的人

-
- 支持创建我的档案、添加伴侣/家人/朋友,并在问答中切换解读对象。数据接口:/api/v1/profiles。 -
- 去完善关系理解 → +

{{ error }}

+ +
暂无档案。请先去首页完成性格探索。
+ 去性格探索 → + 去关系理解 → + + diff --git a/apps/user-h5/src/pages/RelationPage.vue b/apps/user-h5/src/pages/RelationPage.vue index 2bae126..c7d3fab 100644 --- a/apps/user-h5/src/pages/RelationPage.vue +++ b/apps/user-h5/src/pages/RelationPage.vue @@ -3,16 +3,143 @@

关系理解

添加重要的人,了解双方差异与相处建议

+
- 双人分析与深度相处建议将在 P1 接入 API。概览免费可见,完整建议为深度版或会员权益。 + + + +
+ + + +
+

将使用你最近一份「我」的档案进行对比;若没有会先引导去首页创建个人画像。

+ +

{{ error }}

+ + + + diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7768ddb..5396301 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -65,6 +65,14 @@ export function createClient(opts: CreateClientOptions) { call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }), payMock: (orderId: string) => call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }), + createRelationInsight: (profile_a_id: string, profile_b_id: string) => + call<{ + insight: { id: string; report_id?: string } + report: GrowthReport + }>('/api/v1/relation/insight', { + method: 'POST', + body: { profile_a_id, profile_b_id }, + }), } }