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:
jackyu66git
2026-08-02 16:24:57 +08:00
co-authored by Cursor
parent dd94e57277
commit 0f320e040b
49 changed files with 1907 additions and 191 deletions
+21
View File
@@ -0,0 +1,21 @@
# Optional hot reload: install `air`, then run from apps/api: air
# https://github.com/air-verse/air
# Local only — never use as production process manager.
# Policy: .ai/development.md
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/server"
bin = "./tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "testdata"]
delay = 800
stop_on_error = true
[log]
time = false
[misc]
clean_on_exit = true
+44 -5
View File
@@ -2,14 +2,21 @@
package main
import (
"context"
"log"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
"github.com/yuxingu/digital-psychology/apps/api/internal/handler"
"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/report"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
@@ -19,20 +26,52 @@ func main() {
gin.SetMode(gin.ReleaseMode)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
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")
os.Exit(1)
}
defer pool.Close()
migDir := os.Getenv("MIGRATIONS_DIR")
if migDir == "" {
migDir = filepath.Join("migrations")
}
if err := db.Migrate(ctx, pool, migDir); err != nil {
log.Printf("migrate: %v", err)
os.Exit(1)
}
profileSvc := &profile.Service{Repo: &repository.ProfileRepo{Pool: pool}}
reportSvc := &report.Service{
Profiles: &repository.ProfileRepo{Pool: pool},
Reports: &repository.ReportRepo{Pool: pool},
}
r := gin.New()
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id")
c.Next()
})
api := r.Group("/api/v1")
handler.NewHealthHandler().Register(api)
// Placeholder route to demonstrate unified envelope.
api.GET("/ping", func(c *gin.Context) {
response.OK(c, gin.H{"pong": true})
})
addr := cfg.HTTPAddr
log.Printf("yuxingu api listening on %s env=%s", addr, cfg.AppEnv)
if err := r.Run(addr); err != nil {
authed := api.Group("")
authed.Use(middleware.DeviceAuth(pool))
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
(&handler.ReportHandler{Svc: reportSvc}).Register(authed)
log.Printf("yuxingu api listening on %s env=%s", cfg.HTTPAddr, cfg.AppEnv)
if err := r.Run(cfg.HTTPAddr); err != nil {
log.Printf("server stopped: %v", err)
os.Exit(1)
}
+15 -5
View File
@@ -1,8 +1,12 @@
module github.com/yuxingu/digital-psychology/apps/api
go 1.22
go 1.25
require github.com/gin-gonic/gin v1.10.0
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
@@ -15,20 +19,26 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+27 -7
View File
@@ -6,6 +6,7 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -28,12 +29,26 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -47,6 +62,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -66,22 +83,25 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+77
View File
@@ -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
}
+72
View File
@@ -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})
}
+126
View File
@@ -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})
}
+96
View File
@@ -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)
}
+21
View File
@@ -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"`
}
+20
View File
@@ -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"`
}
+146
View File
@@ -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
}
+25
View File
@@ -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
}
+146
View File
@@ -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)
}
+9
View File
@@ -0,0 +1,9 @@
DROP TABLE IF EXISTS deep_accesses;
DROP TABLE IF EXISTS payments;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS memberships;
DROP TABLE IF EXISTS growth_reports;
DROP TABLE IF EXISTS profiles;
DROP TABLE IF EXISTS device_identities;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS schema_migrations;
+105
View File
@@ -0,0 +1,105 @@
-- P1 core schema (see .ai/domain/erd.md)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
status varchar(32) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
CREATE TABLE IF NOT EXISTS device_identities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_key varchar(128) NOT NULL UNIQUE,
user_id uuid NULL REFERENCES users(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_device_identities_user_id ON device_identities(user_id);
CREATE TABLE IF NOT EXISTS profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
relation varchar(16) NOT NULL CHECK (relation IN ('self', 'other')),
display_name varchar(64) NOT NULL DEFAULT '',
birth_date date NOT NULL,
birth_time time NULL,
birth_place varchar(128) NULL,
gender varchar(32) NULL,
relation_type varchar(32) 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_profiles_user_id ON profiles(user_id);
CREATE TABLE IF NOT EXISTS growth_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
profile_id uuid NOT NULL REFERENCES profiles(id),
type varchar(32) NOT NULL,
summary jsonb NOT NULL DEFAULT '{}',
detail 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_growth_reports_user_id ON growth_reports(user_id);
CREATE INDEX IF NOT EXISTS idx_growth_reports_profile_id ON growth_reports(profile_id);
CREATE TABLE IF NOT EXISTS memberships (
user_id uuid PRIMARY KEY REFERENCES users(id),
plan varchar(32) NOT NULL DEFAULT 'month',
status varchar(32) NOT NULL DEFAULT 'expired',
expires_at timestamptz NULL,
ask_quota_left int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
CREATE TABLE IF NOT EXISTS orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
kind varchar(32) NOT NULL,
plan varchar(32) NULL,
report_id uuid NULL REFERENCES growth_reports(id),
amount_cents int NOT NULL DEFAULT 0,
status varchar(32) NOT NULL DEFAULT 'created',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE TABLE IF NOT EXISTS payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders(id),
channel varchar(32) NOT NULL DEFAULT 'mock',
status varchar(32) NOT NULL DEFAULT 'created',
raw jsonb 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_payments_order_id ON payments(order_id);
CREATE TABLE IF NOT EXISTS deep_accesses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
report_id uuid NOT NULL REFERENCES growth_reports(id),
order_id uuid NOT NULL REFERENCES orders(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
UNIQUE (user_id, report_id)
);
CREATE INDEX IF NOT EXISTS idx_deep_accesses_report_id ON deep_accesses(report_id);
+94 -12
View File
@@ -2,31 +2,113 @@
<main class="page">
<button class="back" type="button" @click="$router.back()"> 返回</button>
<h1>个人画像</h1>
<p class="sub" v-if="y">生日{{ y }}-{{ m }}-{{ d }}</p>
<div class="card">
基础画像将由 API 生成免费可见基础结论完整分析为深度版或会员权益 PRD
</div>
<p class="disc">
本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测
</p>
<p v-if="loading" class="sub">正在生成</p>
<p v-else-if="error" class="err">{{ error }}</p>
<template v-else-if="report">
<p class="sub">{{ headline }}</p>
<div class="card">
<p class="line">{{ oneLiner }}</p>
<p class="tip">生活建议{{ lifeTip }}</p>
<div v-if="keywords.length" class="tags">
<span v-for="k in keywords" :key="k">{{ k }}</span>
</div>
</div>
<div v-if="report.has_deep_access && detail" class="card deep">
<h2>完整分析</h2>
<p><strong>行为模式</strong> {{ detail.behavior_pattern }}</p>
<p><strong>关系特点</strong> {{ detail.relation_style }}</p>
<p><strong>成长方向</strong> {{ detail.growth_direction }}</p>
</div>
<div v-else class="card lock">
<p>完整分析行为模式与成长方向可在深度版或成长会员中查看</p>
<button type="button" :disabled="paying" @click="buyDeep">查看深度版模拟支付</button>
</div>
</template>
<p class="disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</p>
</main>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { GrowthReport } from '@yuxingu/types'
import { api } from '../api/client'
const route = useRoute()
const y = computed(() => String(route.query.y || ''))
const m = computed(() => String(route.query.m || ''))
const d = computed(() => String(route.query.d || ''))
const loading = ref(true)
const error = ref('')
const report = ref<GrowthReport | null>(null)
const paying = ref(false)
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
const headline = computed(() => String(summary.value.headline || ''))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const lifeTip = computed(() => String(summary.value.life_tip || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? summary.value.keywords as string[] : []))
async function load() {
loading.value = true
error.value = ''
try {
const reportId = String(route.query.report_id || '')
if (reportId) {
report.value = await api.getReport(reportId)
return
}
const y = Number(route.query.y)
const m = Number(route.query.m)
const d = Number(route.query.d)
if (!y || !m || !d) {
error.value = '请从首页填写生日后进入'
return
}
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
report.value = await api.createPortrait(profile.id)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function buyDeep() {
if (!report.value) return
paying.value = true
error.value = ''
try {
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
await api.payMock(order_id)
report.value = await api.getReport(report.value.id)
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onMounted(load)
</script>
<style scoped>
.page{padding:16px}
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
h1{font-size:22px}
h2{font-size:16px;margin-bottom:8px}
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
.err{color:var(--yxg-pri);font-size:13px;margin:8px 0}
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555;margin-bottom:12px}
.line{font-size:15px;color:#333}
.tip{margin-top:8px;color:#666}
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
.tags span{background:var(--yxg-bg-start,#ffe4e4);color:var(--yxg-pri);padding:4px 10px;border-radius:999px;font-size:12px}
.lock button{
margin-top:12px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
}
.lock button:disabled{opacity:.6}
.disc{font-size:11px;color:#bbb;margin-top:16px;line-height:1.5}
.deep p{margin-top:8px}
</style>