feat: P1 profile/portrait API and freeze local-dev environment
Add host-first environment contracts (Local vs CI vs Prod), deps-only compose, and the Profile → Portrait → deep-access mock payment slice with device identity and auto-migrate on API startup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Package db opens Postgres and runs SQL migrations.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Connect opens a pgx pool using DATABASE_URL-style DSN.
|
||||
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgxpool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// Migrate applies *.up.sql files under dir that are not yet recorded.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool, dir string) error {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
var ups []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".up.sql") {
|
||||
ups = append(ups, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(ups)
|
||||
|
||||
for _, name := range ups {
|
||||
version := strings.TrimSuffix(name, ".up.sql")
|
||||
var exists bool
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, version,
|
||||
).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// DDL (incl. CREATE EXTENSION) may not run inside a transaction.
|
||||
if _, err := pool.Exec(ctx, string(body)); err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO schema_migrations(version) VALUES ($1)`, version,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/profile"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// ProfileHandler exposes personal archive APIs.
|
||||
type ProfileHandler struct {
|
||||
Svc *profile.Service
|
||||
}
|
||||
|
||||
// Register mounts profile routes (requires device auth on group).
|
||||
func (h *ProfileHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/profiles", h.List)
|
||||
rg.POST("/profiles", h.Create)
|
||||
}
|
||||
|
||||
type createProfileReq struct {
|
||||
Relation string `json:"relation" binding:"required"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BirthDate string `json:"birth_date" binding:"required"`
|
||||
RelationType *string `json:"relation_type"`
|
||||
}
|
||||
|
||||
// Create handles POST /profiles.
|
||||
func (h *ProfileHandler) Create(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req createProfileReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
birth, err := time.Parse("2006-01-02", req.BirthDate)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "birth_date must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
p, err := h.Svc.Create(c.Request.Context(), userID, profile.CreateInput{
|
||||
Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType,
|
||||
})
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30001, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, p)
|
||||
}
|
||||
|
||||
// List handles GET /profiles.
|
||||
func (h *ProfileHandler) List(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
list, err := h.Svc.List(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50002, "list failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": list})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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/report"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// ReportHandler exposes portrait reports and commerce mock.
|
||||
type ReportHandler struct {
|
||||
Svc *report.Service
|
||||
}
|
||||
|
||||
// Register mounts report/commerce routes.
|
||||
func (h *ReportHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.POST("/reports/portrait", h.CreatePortrait)
|
||||
rg.GET("/reports/:id", h.Get)
|
||||
rg.POST("/orders", h.CreateOrder)
|
||||
rg.POST("/orders/:id/pay-mock", h.PayMock)
|
||||
}
|
||||
|
||||
// CreatePortrait handles POST /reports/portrait.
|
||||
func (h *ReportHandler) CreatePortrait(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"`
|
||||
}
|
||||
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
|
||||
}
|
||||
rep, err := h.Svc.CreatePortrait(c.Request.Context(), userID, pid)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, rep)
|
||||
}
|
||||
|
||||
// Get handles GET /reports/:id.
|
||||
func (h *ReportHandler) Get(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
rid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
|
||||
return
|
||||
}
|
||||
rep, err := h.Svc.Get(c.Request.Context(), userID, rid)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 40401, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, rep)
|
||||
}
|
||||
|
||||
// CreateOrder handles POST /orders.
|
||||
func (h *ReportHandler) CreateOrder(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Kind string `json:"kind" binding:"required"`
|
||||
Plan string `json:"plan"`
|
||||
ReportID *string `json:"report_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
var rid *uuid.UUID
|
||||
if req.ReportID != nil && *req.ReportID != "" {
|
||||
id, err := uuid.Parse(*req.ReportID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid report_id")
|
||||
return
|
||||
}
|
||||
rid = &id
|
||||
}
|
||||
oid, err := h.Svc.CreateOrder(c.Request.Context(), userID, report.CreateOrderInput{
|
||||
Kind: req.Kind, Plan: req.Plan, ReportID: rid,
|
||||
})
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30003, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"order_id": oid.String()})
|
||||
}
|
||||
|
||||
// PayMock handles POST /orders/:id/pay-mock.
|
||||
func (h *ReportHandler) PayMock(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
oid, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.PayMock(c.Request.Context(), userID, oid); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30004, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"paid": true})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const UserIDKey ctxKey = "user_id"
|
||||
const DeviceKeyHeader = "X-Device-Key"
|
||||
|
||||
// DeviceAuth resolves or creates a Visitor→User via device key.
|
||||
func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.GetHeader(DeviceKeyHeader)
|
||||
if key == "" {
|
||||
key = newDeviceKey()
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
}
|
||||
userID, err := ensureUser(c.Request.Context(), pool, key)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(string(UserIDKey), userID.String())
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UserIDFromContext returns the authenticated user id.
|
||||
func UserIDFromContext(c *gin.Context) (uuid.UUID, bool) {
|
||||
v, ok := c.Get(string(UserIDKey))
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
id, err := uuid.Parse(v.(string))
|
||||
return id, err == nil
|
||||
}
|
||||
|
||||
func ensureUser(ctx context.Context, pool *pgxpool.Pool, deviceKey string) (uuid.UUID, error) {
|
||||
var userID *uuid.UUID
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT user_id FROM device_identities
|
||||
WHERE device_key=$1 AND deleted_at IS NULL`, deviceKey,
|
||||
).Scan(&userID)
|
||||
if err == nil && userID != nil {
|
||||
return *userID, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var uid uuid.UUID
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO users DEFAULT VALUES RETURNING id`,
|
||||
).Scan(&uid); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
ON CONFLICT (device_key) DO UPDATE SET user_id=EXCLUDED.user_id, updated_at=now()`,
|
||||
deviceKey, uid,
|
||||
); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
func newDeviceKey() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Profile is a personal archive (self or other).
|
||||
type Profile struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Relation string `json:"relation"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BirthDate time.Time `json:"birth_date"`
|
||||
BirthTime *string `json:"birth_time,omitempty"`
|
||||
BirthPlace *string `json:"birth_place,omitempty"`
|
||||
Gender *string `json:"gender,omitempty"`
|
||||
RelationType *string `json:"relation_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GrowthReport is a deliverable with free summary and gated detail.
|
||||
type GrowthReport struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ProfileID uuid.UUID `json:"profile_id"`
|
||||
Type string `json:"type"`
|
||||
Summary json.RawMessage `json:"summary"`
|
||||
Detail json.RawMessage `json:"detail,omitempty"`
|
||||
HasDeep bool `json:"has_deep_access"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package portrait builds deterministic personal-portrait content from birth date.
|
||||
// Copy follows .ai/product/lexicon.md — exploration / analysis, not fortune-telling.
|
||||
package portrait
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Output is free summary + gated detail for a GrowthReport.
|
||||
type Output struct {
|
||||
Summary map[string]any `json:"summary"`
|
||||
Detail map[string]any `json:"detail"`
|
||||
}
|
||||
|
||||
// Build generates 个人画像 content from a birth date (deterministic).
|
||||
func Build(birth time.Time, displayName string) Output {
|
||||
y, m, d := birth.Date()
|
||||
num := reduce(y) + reduce(int(m)) + reduce(d)
|
||||
for num > 9 {
|
||||
num = reduce(num)
|
||||
}
|
||||
trait := traits[num%len(traits)]
|
||||
name := displayName
|
||||
if name == "" {
|
||||
name = "你"
|
||||
}
|
||||
|
||||
summary := map[string]any{
|
||||
"title": "基础画像",
|
||||
"headline": fmt.Sprintf("%s更偏「%s」的互动风格", name, trait.Label),
|
||||
"keywords": trait.Keywords,
|
||||
"one_liner": trait.OneLiner,
|
||||
"life_tip": trait.LifeTip,
|
||||
"pattern_key": num,
|
||||
}
|
||||
detail := map[string]any{
|
||||
"title": "完整分析",
|
||||
"behavior_pattern": trait.Behavior,
|
||||
"relation_style": trait.Relation,
|
||||
"growth_direction": trait.Growth,
|
||||
"daily_suggestions": []string{
|
||||
trait.LifeTip,
|
||||
"遇到分歧时,先复述对方观点再表达自己的需要。",
|
||||
"用一周记录情绪与精力高峰,找到更适合自己的节奏。",
|
||||
},
|
||||
}
|
||||
return Output{Summary: summary, Detail: detail}
|
||||
}
|
||||
|
||||
type trait struct {
|
||||
Label string
|
||||
Keywords []string
|
||||
OneLiner string
|
||||
LifeTip string
|
||||
Behavior string
|
||||
Relation string
|
||||
Growth string
|
||||
}
|
||||
|
||||
var traits = []trait{
|
||||
{
|
||||
Label: "稳健探索", Keywords: []string{"条理", "耐心", "观察"},
|
||||
OneLiner: "你习惯先理解再行动,适合把复杂事情拆成小步。",
|
||||
LifeTip: "今天给自己一段不被打断的专注时间。",
|
||||
Behavior: "决策偏审慎,信息充分时执行力更强;压力下可能拖延。",
|
||||
Relation: "更愿意用行动表达关心,需要对方给予明确反馈。",
|
||||
Growth: "练习在信息不完整时做小步试验,积累行动信心。",
|
||||
},
|
||||
{
|
||||
Label: "热情连接", Keywords: []string{"表达", "共鸣", "主动"},
|
||||
OneLiner: "你容易带动气氛,也需要被真诚回应。",
|
||||
LifeTip: "把想说的话写下来,再选择合适的时机分享。",
|
||||
Behavior: "行动快、反馈敏感;情绪起伏会影响专注时长。",
|
||||
Relation: "重视即时沟通,冷处理容易让你感到不安。",
|
||||
Growth: "学会区分「被回应」与「被认同」,减少过度解读。",
|
||||
},
|
||||
{
|
||||
Label: "理性澄清", Keywords: []string{"分析", "边界", "清晰"},
|
||||
OneLiner: "你擅长把模糊感受变成可讨论的问题。",
|
||||
LifeTip: "睡前做一次简短复盘:今天最有价值的一件事是什么。",
|
||||
Behavior: "偏好逻辑框架;在情绪场域可能显得抽离。",
|
||||
Relation: "沟通时需要结构和具体例子,忌空泛安慰。",
|
||||
Growth: "在分析之外,练习先接纳情绪再谈方案。",
|
||||
},
|
||||
{
|
||||
Label: "柔韧调节", Keywords: []string{"适应", "体察", "平衡"},
|
||||
OneLiner: "你善于照顾氛围,也别忘了照顾自己的节奏。",
|
||||
LifeTip: "安排一次轻度活动,帮助身心回到平稳状态。",
|
||||
Behavior: "弹性强,容易迁就;长期可能积压需求。",
|
||||
Relation: "更在意关系和谐,冲突时倾向先安抚场面。",
|
||||
Growth: "练习用「我需要…」表达边界,而不是只做协调者。",
|
||||
},
|
||||
{
|
||||
Label: "目标推进", Keywords: []string{"决断", "效率", "成果"},
|
||||
OneLiner: "你推动事情落地的能力突出,记得留白给休息。",
|
||||
LifeTip: "把今日目标收束到一件最重要的事。",
|
||||
Behavior: "结果导向,节奏偏快;对低效协作耐心有限。",
|
||||
Relation: "欣赏直接沟通的人,含糊表态会消耗信任。",
|
||||
Growth: "把「效率」与「关系维护」列为同等优先级的周目标。",
|
||||
},
|
||||
{
|
||||
Label: "内观沉淀", Keywords: []string{"深度", "独立", "洞察"},
|
||||
OneLiner: "你习惯向内理解世界,适合深度思考类任务。",
|
||||
LifeTip: "留出安静独处时间,整理近期的想法与感受。",
|
||||
Behavior: "思考深入,对外表达可能滞后于内心结论。",
|
||||
Relation: "需要安全感和节奏感,突然施压会让你退回内在。",
|
||||
Growth: "把洞察翻译成可分享的语言,让重要的人跟上你。",
|
||||
},
|
||||
{
|
||||
Label: "创意发散", Keywords: []string{"想象", "灵感", "可能"},
|
||||
OneLiner: "你容易看到多种可能性,适合用原型验证想法。",
|
||||
LifeTip: "用纸笔画出今天最想尝试的一个小实验。",
|
||||
Behavior: "点子多、切换快;收尾与复盘是短板。",
|
||||
Relation: "喜欢有趣的对话,重复与僵化会让你疏离。",
|
||||
Growth: "为每个灵感设定「最小完成定义」,提高闭环率。",
|
||||
},
|
||||
{
|
||||
Label: "责任担当", Keywords: []string{"可靠", "承诺", "稳定"},
|
||||
OneLiner: "你重视承诺与秩序,是团队里让人安心的存在。",
|
||||
LifeTip: "检查一下是否把别人的期待误当成自己的必须。",
|
||||
Behavior: "可靠且自律;过度负责时容易耗竭。",
|
||||
Relation: "用持续在场表达在乎,需要被看见付出。",
|
||||
Growth: "练习委托与求助,让支持系统真正运转起来。",
|
||||
},
|
||||
{
|
||||
Label: "敏锐觉察", Keywords: []string{"细腻", "直觉", "体贴"},
|
||||
OneLiner: "你对情绪与细节敏感,适合需要同理的场景。",
|
||||
LifeTip: "觉察身体信号:紧张时先放慢呼吸再回应。",
|
||||
Behavior: "感知力强;信息过载时容易内耗。",
|
||||
Relation: "能很快读到对方状态,也易被情绪感染。",
|
||||
Growth: "建立「感受—事实—选择」三步,减少被情绪牵着走。",
|
||||
},
|
||||
}
|
||||
|
||||
func reduce(n int) int {
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
sum := 0
|
||||
for n > 0 {
|
||||
sum += n % 10
|
||||
n /= 10
|
||||
}
|
||||
return sum
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package portrait
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildDeterministic(t *testing.T) {
|
||||
birth := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
a := Build(birth, "小愈")
|
||||
b := Build(birth, "小愈")
|
||||
if a.Summary["headline"] != b.Summary["headline"] {
|
||||
t.Fatalf("expected deterministic headline")
|
||||
}
|
||||
if a.Summary["one_liner"] == nil || a.Detail["growth_direction"] == nil {
|
||||
t.Fatalf("missing summary/detail fields")
|
||||
}
|
||||
for _, kw := range []string{"运势", "吉凶", "算命"} {
|
||||
s := a.Summary["one_liner"].(string) + a.Detail["behavior_pattern"].(string)
|
||||
if strings.Contains(s, kw) {
|
||||
t.Fatalf("forbidden word %q in portrait copy", kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
)
|
||||
|
||||
// ProfileRepo persists profiles.
|
||||
type ProfileRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Create inserts a profile.
|
||||
func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string) (*model.Profile, error) {
|
||||
p := &model.Profile{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING id, user_id, relation, display_name, birth_date, created_at`,
|
||||
userID, relation, name, birth, relationType,
|
||||
).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.RelationType = relationType
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListByUser returns non-deleted profiles.
|
||||
func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at
|
||||
FROM profiles WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.Profile
|
||||
for rows.Next() {
|
||||
var p model.Profile
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetForUser loads a profile owned by user.
|
||||
func (r *ProfileRepo) GetForUser(ctx context.Context, userID, profileID uuid.UUID) (*model.Profile, error) {
|
||||
p := &model.Profile{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at
|
||||
FROM profiles WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
|
||||
profileID, userID,
|
||||
).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ReportRepo persists growth reports and access checks.
|
||||
type ReportRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// Create inserts a growth report.
|
||||
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
|
||||
VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
|
||||
userID, profileID, typ, summary, detail,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// GetForUser loads a report owned by user.
|
||||
func (r *ReportRepo) GetForUser(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep := &model.GrowthReport{}
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, profile_id, type, summary, detail, created_at
|
||||
FROM growth_reports WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
|
||||
reportID, userID,
|
||||
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// HasDeepAccess reports whether user purchased deep access for report.
|
||||
func (r *ReportRepo) HasDeepAccess(ctx context.Context, userID, reportID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM deep_accesses
|
||||
WHERE user_id=$1 AND report_id=$2 AND deleted_at IS NULL
|
||||
)`, userID, reportID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// HasActiveMembership checks growth membership.
|
||||
func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM memberships
|
||||
WHERE user_id=$1 AND status='active' AND expires_at > now() AND deleted_at IS NULL
|
||||
)`, userID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// CreateOrder inserts an order.
|
||||
func (r *ReportRepo) CreateOrder(ctx context.Context, userID uuid.UUID, kind, plan string, reportID *uuid.UUID, amount int) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO orders(user_id, kind, plan, report_id, amount_cents, status)
|
||||
VALUES ($1,$2,$3,$4,$5,'created') RETURNING id`,
|
||||
userID, kind, nullIfEmpty(plan), reportID, amount,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// PayMock marks order paid and grants entitlement.
|
||||
func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var kind string
|
||||
var reportID *uuid.UUID
|
||||
var plan *string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT kind, report_id, plan FROM orders
|
||||
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL FOR UPDATE`,
|
||||
orderID, userID,
|
||||
).Scan(&kind, &reportID, &plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE orders SET status='paid', updated_at=now() WHERE id=$1`, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO payments(order_id, channel, status) VALUES ($1,'mock','paid')`, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
switch kind {
|
||||
case "deep_access":
|
||||
if reportID == nil {
|
||||
return errMissingReport
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO deep_accesses(user_id, report_id, order_id)
|
||||
VALUES ($1,$2,$3)
|
||||
ON CONFLICT (user_id, report_id) DO NOTHING`, userID, *reportID, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "membership":
|
||||
p := "month"
|
||||
if plan != nil && *plan != "" {
|
||||
p = *plan
|
||||
}
|
||||
days := 31
|
||||
if p == "quarter" {
|
||||
days = 92
|
||||
} else if p == "year" {
|
||||
days = 366
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
|
||||
VALUES ($1,$2,'active', now() + ($3::text || ' days')::interval, 100)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
plan=EXCLUDED.plan, status='active',
|
||||
expires_at=EXCLUDED.expires_at, ask_quota_left=100, updated_at=now()`,
|
||||
userID, p, days); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
var errMissingReport = errString("report_id required for deep_access")
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service manages personal archives.
|
||||
type Service struct {
|
||||
Repo *repository.ProfileRepo
|
||||
}
|
||||
|
||||
// CreateInput is validated create payload.
|
||||
type CreateInput struct {
|
||||
Relation string
|
||||
DisplayName string
|
||||
BirthDate time.Time
|
||||
RelationType *string
|
||||
}
|
||||
|
||||
// Create stores a profile for the user.
|
||||
func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) (*model.Profile, error) {
|
||||
if in.Relation != "self" && in.Relation != "other" {
|
||||
return nil, errors.New("relation must be self or other")
|
||||
}
|
||||
if in.BirthDate.IsZero() {
|
||||
return nil, errors.New("birth_date required")
|
||||
}
|
||||
name := in.DisplayName
|
||||
if name == "" {
|
||||
if in.Relation == "self" {
|
||||
name = "我"
|
||||
} else {
|
||||
name = "TA"
|
||||
}
|
||||
}
|
||||
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType)
|
||||
}
|
||||
|
||||
// List returns user's profiles.
|
||||
func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) {
|
||||
return s.Repo.ListByUser(ctx, userID)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service creates and reads growth reports with entitlement trimming.
|
||||
type Service struct {
|
||||
Profiles *repository.ProfileRepo
|
||||
Reports *repository.ReportRepo
|
||||
}
|
||||
|
||||
// CreatePortrait builds and stores a portrait report.
|
||||
func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) {
|
||||
p, err := s.Profiles.GetForUser(ctx, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
out := portrait.Build(p.BirthDate, p.DisplayName)
|
||||
sum, _ := json.Marshal(out.Summary)
|
||||
det, _ := json.Marshal(out.Detail)
|
||||
rep, err := s.Reports.Create(ctx, userID, profileID, "portrait", sum, det)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
// Get returns a report with detail gated.
|
||||
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
|
||||
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
|
||||
if err != nil {
|
||||
return nil, errors.New("report not found")
|
||||
}
|
||||
return s.applyEntitlement(ctx, userID, rep)
|
||||
}
|
||||
|
||||
func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) {
|
||||
deep, err := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vip, err := s.Reports.HasActiveMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rep.HasDeep = deep || vip
|
||||
if !rep.HasDeep {
|
||||
rep.Detail = nil
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// CreateOrderInput for commerce.
|
||||
type CreateOrderInput struct {
|
||||
Kind string
|
||||
Plan string
|
||||
ReportID *uuid.UUID
|
||||
}
|
||||
|
||||
// CreateOrder starts membership or deep_access order.
|
||||
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) {
|
||||
if in.Kind != "membership" && in.Kind != "deep_access" {
|
||||
return uuid.Nil, errors.New("invalid kind")
|
||||
}
|
||||
if in.Kind == "deep_access" && in.ReportID == nil {
|
||||
return uuid.Nil, errors.New("report_id required")
|
||||
}
|
||||
amount := 990
|
||||
if in.Kind == "membership" {
|
||||
amount = 2500
|
||||
}
|
||||
return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount)
|
||||
}
|
||||
|
||||
// PayMock completes mock payment.
|
||||
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
|
||||
return s.Reports.PayMock(ctx, userID, orderID)
|
||||
}
|
||||
Reference in New Issue
Block a user