feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具

落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+17
View File
@@ -0,0 +1,17 @@
# 愈心谷 API
Go + Gin + Postgres。本命/合盘星历依赖 **Swiss Ephemeris**`github.com/tejzpr/go-swisseph`**AGPL-3.0**)。
## 构建要求
- Go 1.22+
- **CGO_ENABLED=1** 与 C 编译器(clang/gcc
- 默认使用 **Moshier** 星历(无需外挂 ephe 文件)
- 可选:设置 `SE_EPHE_PATH` / `EPHEMERIS_PATH` 指向 Swiss Ephemeris 数据目录以启用更高精度(`FlagSwieph`
```bash
CGO_ENABLED=1 go test ./...
CGO_ENABLED=1 go run ./cmd/server
```
商业部署须遵守 Swiss Ephemeris 许可(AGPL 或 Astrodienst 专业授权)。
+7 -37
View File
@@ -12,14 +12,7 @@ import (
"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/relation"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/scale"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
"github.com/yuxingu/digital-psychology/apps/api/internal/httpserver"
)
func main() {
@@ -48,35 +41,12 @@ func main() {
os.Exit(1)
}
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}
scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
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)
api.GET("/ping", func(c *gin.Context) {
response.OK(c, gin.H{"pong": true})
})
authed := api.Group("")
authed.Use(middleware.DeviceAuth(pool))
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
(&handler.ReportHandler{Svc: reportSvc}).Register(authed)
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
(&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
r := httpserver.NewRouter(pool, cfg)
if cfg.DeepSeek.Enabled() {
log.Printf("ask: deepseek enabled model=%s", cfg.DeepSeek.Model)
} else {
log.Printf("ask: deepseek disabled (set deepseek.api_key in config.local.yaml) — using rule engine")
}
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)
+25
View File
@@ -0,0 +1,25 @@
# Copy to config.local.yaml and fill secrets.
# config.local.yaml is gitignored (*.local). Never commit real keys.
app:
env: dev
http_addr: ":8080"
database:
host: 127.0.0.1
port: 5432
user: yuxingu
password: yuxingu
name: yuxingu
sslmode: disable
deepseek:
api_key: "" # required for real Ask replies
base_url: "https://api.deepseek.com"
model: "deepseek-chat"
timeout_sec: 60
# jwt:
# secret: ""
# payment:
# mock: true
+2 -1
View File
@@ -6,6 +6,8 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/tejzpr/go-swisseph v1.0.2
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -40,5 +42,4 @@ require (
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
)
+2
View File
@@ -76,6 +76,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tejzpr/go-swisseph v1.0.2 h1:S/uO8aPcWjORJmqYydDxBx5h/LUhzxyqLVEMFt8QC1Q=
github.com/tejzpr/go-swisseph v1.0.2/go.mod h1:LPZ5M7ejE7+mBzaow3ACezJfsjgRCsQ/LZrz97LDXSQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+231
View File
@@ -0,0 +1,231 @@
// Package ask builds deterministic growth-assistant replies (rule fallback).
// Copy follows .ai/product/lexicon.md — companion for self-understanding, not fortune-telling.
package ask
import (
"fmt"
"strings"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
)
// ReplyInput is context for a single assistant reply.
type ReplyInput struct {
DisplayName string
BirthDate time.Time
Relation string // self | other
Scene string
UserMessage string
}
// BuildReply returns a profile-aware assistant message with actionable detail.
func BuildReply(in ReplyInput) string {
name := in.DisplayName
if name == "" {
if in.Relation == "other" {
name = "TA"
} else {
name = "你"
}
}
out := portrait.Build(in.BirthDate, name)
style := str(out.Summary["style_label"])
if style == "" {
style = str(out.Summary["headline"])
}
one := str(out.Summary["one_liner"])
tip := str(out.Summary["life_tip"])
overview := str(out.Summary["overview"])
sceneHint := sceneLine(in.Scene, in.UserMessage)
focus := detectFocus(in.UserMessage)
sectionBody := sectionByFocus(out.Detail, focus)
scripts := strSlice(out.Detail["conversation_scripts"])
scriptLine := ""
if len(scripts) > 0 {
scriptLine = "可以试着说:「" + scripts[0] + "」"
}
plan := firstPlan(out.Detail["growth_plan"])
var body string
switch focus {
case "relation":
body = fmt.Sprintf(
"结合「%s」偏「%s」的档案:%s\n\n关系侧重点:%s\n\n小建议:%s\n%s\n本周可做:%s",
name, style, one, pick(sectionBody, "在关系里先复述对方感受,再表达需要。"), tip, scriptLine, pick(plan, tip),
)
case "career":
body = fmt.Sprintf(
"从「%s / %s」看职业节奏:%s\n\n%s\n\n可执行:%s\n本周:%s",
name, style, one, pick(sectionBody, overview), tip, pick(plan, "选一件能发挥你节奏优势的小事推进。"),
)
case "emotion":
body = fmt.Sprintf(
"我听到你在整理情绪。对照「%s」:%s\n\n%s\n\n调节建议:%s\n%s\n本周:%s",
style, one, pick(sectionBody, "先命名感受,再决定行动。"), tip, scriptLine, pick(plan, "给自己 10 分钟不评判地写下此刻感受。"),
)
case "life":
body = fmt.Sprintf(
"关于生活节奏(%s):%s\n\n%s\n\n今日建议:%s\n本周:%s",
style, one, pick(sectionBody, overview), tip, pick(plan, tip),
)
default:
body = fmt.Sprintf(
"我是愈心谷的成长助手,会结合档案陪你一起看。\n\n「%s」更偏「%s」:%s\n\n%s\n\n生活建议:%s\n%s\n本周可做:%s",
name, style, one, trimRunes(overview, 120), tip, scriptLine, pick(plan, tip),
)
}
disclaimer := "以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
if sceneHint != "" {
return sceneHint + "\n\n" + strings.TrimSpace(body) + "\n\n" + disclaimer
}
return strings.TrimSpace(body) + "\n\n" + disclaimer
}
func sectionByFocus(detail map[string]any, focus string) string {
titleHint := map[string]string{
"relation": "关系",
"career": "事业",
"emotion": "情绪",
"life": "生活",
"self": "性格",
}[focus]
secs, ok := detail["sections"].([]map[string]any)
if !ok {
// also []any after some encodings
if raw, ok2 := detail["sections"].([]any); ok2 {
for _, item := range raw {
m, _ := item.(map[string]any)
if strings.Contains(str(m["title"]), titleHint) {
return str(m["body"])
}
}
}
switch focus {
case "relation":
return str(detail["relation_style"])
case "career", "self":
return str(detail["behavior_pattern"])
default:
return str(detail["growth_direction"])
}
}
for _, s := range secs {
if strings.Contains(str(s["title"]), titleHint) {
return str(s["body"])
}
}
if len(secs) > 0 {
return str(secs[0]["body"])
}
return ""
}
func firstPlan(v any) string {
arr, ok := v.([]map[string]any)
if ok && len(arr) > 0 {
return str(arr[0]["focus"])
}
raw, ok := v.([]any)
if ok && len(raw) > 0 {
if m, ok := raw[0].(map[string]any); ok {
return str(m["focus"])
}
}
return ""
}
func pick(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
}
return b
}
func trimRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func strSlice(v any) []string {
if arr, ok := v.([]string); 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
}
func sceneLine(scene, msg string) string {
s := strings.TrimSpace(scene)
if s == "" {
s = detectScene(msg)
}
switch s {
case "self":
return "场景:认识自己"
case "relation":
return "场景:理解关系"
case "career":
return "场景:职业探索"
case "emotion":
return "场景:情绪整理"
case "life":
return "场景:生活建议"
default:
return ""
}
}
func detectScene(msg string) string {
return detectFocus(msg)
}
func detectFocus(msg string) string {
m := strings.ToLower(msg)
switch {
case containsAny(m, "关系", "相处", "伴侣", "朋友", "沟通", "TA", "他", "她"):
return "relation"
case containsAny(m, "工作", "职业", "面试", "事业", "选择"):
return "career"
case containsAny(m, "情绪", "焦虑", "难过", "压力", "心情", "害怕"):
return "emotion"
case containsAny(m, "作息", "睡眠", "习惯", "生活", "饮食", "运动"):
return "life"
default:
return "self"
}
}
func containsAny(s string, words ...string) bool {
for _, w := range words {
if strings.Contains(s, w) {
return true
}
}
return false
}
func str(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
+40
View File
@@ -0,0 +1,40 @@
package ask
import (
"strings"
"testing"
"time"
)
func TestBuildReply_profileAware(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
out := BuildReply(ReplyInput{
DisplayName: "我",
BirthDate: birth,
Relation: "self",
Scene: "self",
UserMessage: "我想更了解自己",
})
if !strings.Contains(out, "我") {
t.Fatalf("expected name in reply: %s", out)
}
if strings.Contains(out, "算命") || strings.Contains(out, "运势") || strings.Contains(out, "吉凶") {
t.Fatalf("forbidden lexicon in reply: %s", out)
}
if !strings.Contains(out, "不构成") {
t.Fatalf("expected disclaimer: %s", out)
}
}
func TestBuildReply_relationFocus(t *testing.T) {
birth := time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC)
out := BuildReply(ReplyInput{
DisplayName: "TA",
BirthDate: birth,
Relation: "other",
UserMessage: "和伴侣沟通总是卡住怎么办",
})
if !strings.Contains(out, "关系") && !strings.Contains(out, "相处") && !strings.Contains(out, "复述") {
t.Fatalf("expected relation-oriented reply: %s", out)
}
}
+58
View File
@@ -0,0 +1,58 @@
// Package companion provides solar-term tips and mood helpers.
package companion
import "time"
// SolarTerm is today's life tip (not fortune).
type SolarTerm struct {
Name string `json:"name"`
Tip string `json:"tip"`
Date string `json:"date"`
}
var terms = []struct {
Start int
Name string
Tip string
}{
{1, "小寒", "天冷宜温补作息,少熬夜,给身心留一点缓冲。"},
{15, "大寒", "注意保暖与情绪稳定,适合整理这一年想放下的事。"},
{32, "立春", "万物复苏,适合定一个小而清晰的成长目标。"},
{47, "雨水", "润物无声,试试每天记录一件让你安心的小事。"},
{62, "惊蛰", "能量回升,适合重启被搁置的习惯,别一次做太多。"},
{77, "春分", "昼夜均衡,留意工作与休息的平衡,沟通也留余地。"},
{92, "清明", "适合回顾与告别,把情绪说给信任的人或写下来。"},
{107, "谷雨", "播种季,把计划拆成可完成的一步即可。"},
{122, "立夏", "心气易浮,留出安静时段,喝水、慢走都有帮助。"},
{137, "小满", "不必求满,进度到八成就值得肯定自己。"},
{152, "芒种", "忙碌中记得停一下,问自己:此刻最需要什么?"},
{167, "夏至", "白昼最长,保护睡眠,午后可短暂休息恢复专注。"},
{183, "小暑", "炎热易烦躁,沟通前先降温自己的情绪。"},
{198, "大暑", "宜清淡饮食与适度运动,别用透支换效率。"},
{213, "立秋", "开始收敛节奏,复盘上半年,调整下阶段重心。"},
{228, "处暑", "暑气渐消,适合温和地恢复规律作息。"},
{243, "白露", "早晚温差大,关照身体的同时关照情绪波动。"},
{258, "秋分", "又一次平衡点,整理关系与边界,轻装前行。"},
{273, "寒露", "宜保暖与内观,少责备自己,多一点耐心。"},
{288, "霜降", "适合沉淀,读一点喜欢的内容,给心灵加温。"},
{303, "立冬", "进入收藏季,减少无效社交消耗,守护精力。"},
{318, "小雪", "天寒宜静,可用书写梳理焦虑与期待。"},
{333, "大雪", "慢下来也是前进,允许自己休息而不内疚。"},
{348, "冬至", "一阳初生,适合与亲近的人连结,表达感谢。"},
}
// TodaySolar returns approximate solar term by day-of-year (not ephemeris).
func TodaySolar(now time.Time) SolarTerm {
doy := now.YearDay()
cur := terms[0]
for _, t := range terms {
if doy >= t.Start {
cur = t
}
}
return SolarTerm{
Name: cur.Name,
Tip: cur.Tip,
Date: now.Format("2006-01-02"),
}
}
+20
View File
@@ -0,0 +1,20 @@
package companion
import (
"strings"
"testing"
"time"
)
func TestTodaySolar(t *testing.T) {
term := TodaySolar(time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC))
if term.Name == "" || term.Tip == "" {
t.Fatalf("empty term: %#v", term)
}
blob := term.Name + term.Tip
for _, bad := range []string{"算命", "占卜"} {
if strings.Contains(blob, bad) {
t.Fatalf("forbidden %q", bad)
}
}
}
+177 -12
View File
@@ -1,27 +1,192 @@
// Package config loads process configuration from environment variables.
// Package config loads process configuration from YAML + optional env overrides.
package config
import "os"
import (
"fmt"
"log"
"net/url"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Config holds runtime settings for the API server.
type Config struct {
HTTPAddr string
DatabaseURL string
AppEnv string
DeepSeek DeepSeekConfig
}
// Load reads configuration from the environment with safe defaults for local dev.
// DeepSeekConfig for Ask LLM.
type DeepSeekConfig struct {
APIKey string
BaseURL string
Model string
TimeoutSec int
}
type fileConfig struct {
App struct {
Env string `yaml:"env"`
HTTPAddr string `yaml:"http_addr"`
} `yaml:"app"`
Database struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
Name string `yaml:"name"`
SSLMode string `yaml:"sslmode"`
} `yaml:"database"`
DeepSeek struct {
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
TimeoutSec int `yaml:"timeout_sec"`
} `yaml:"deepseek"`
}
// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides.
func Load() Config {
return Config{
HTTPAddr: getenv("HTTP_ADDR", ":8080"),
DatabaseURL: getenv("DATABASE_URL", "postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable"),
AppEnv: getenv("APP_ENV", "dev"),
cfg := Config{
HTTPAddr: ":8080",
DatabaseURL: "postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable",
AppEnv: "dev",
DeepSeek: DeepSeekConfig{
BaseURL: "https://api.deepseek.com",
Model: "deepseek-chat",
TimeoutSec: 60,
},
}
path := resolveConfigPath()
if path != "" {
if err := mergeFile(&cfg, path); err != nil {
log.Printf("config: load %s: %v (using defaults/env)", path, err)
} else {
log.Printf("config: loaded %s", path)
}
} else {
log.Printf("config: no config.local.yaml found — copy config.example.yaml → config.local.yaml")
}
applyEnv(&cfg)
return cfg
}
func resolveConfigPath() string {
if p := os.Getenv("CONFIG_PATH"); p != "" {
return p
}
candidates := []string{
"config.local.yaml",
"apps/api/config.local.yaml",
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
func mergeFile(cfg *Config, path string) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var f fileConfig
if err := yaml.Unmarshal(raw, &f); err != nil {
return err
}
if f.App.Env != "" {
cfg.AppEnv = f.App.Env
}
if f.App.HTTPAddr != "" {
cfg.HTTPAddr = f.App.HTTPAddr
}
if f.Database.Host != "" || f.Database.User != "" || f.Database.Name != "" {
cfg.DatabaseURL = buildDatabaseURL(f)
}
if f.DeepSeek.APIKey != "" {
cfg.DeepSeek.APIKey = f.DeepSeek.APIKey
}
if f.DeepSeek.BaseURL != "" {
cfg.DeepSeek.BaseURL = strings.TrimRight(f.DeepSeek.BaseURL, "/")
}
if f.DeepSeek.Model != "" {
cfg.DeepSeek.Model = f.DeepSeek.Model
}
if f.DeepSeek.TimeoutSec > 0 {
cfg.DeepSeek.TimeoutSec = f.DeepSeek.TimeoutSec
}
return nil
}
func buildDatabaseURL(f fileConfig) string {
host := f.Database.Host
if host == "" {
host = "127.0.0.1"
}
port := f.Database.Port
if port == 0 {
port = 5432
}
user := f.Database.User
if user == "" {
user = "yuxingu"
}
pass := f.Database.Password
name := f.Database.Name
if name == "" {
name = "yuxingu"
}
ssl := f.Database.SSLMode
if ssl == "" {
ssl = "disable"
}
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: fmt.Sprintf("%s:%d", host, port),
Path: "/" + name,
}
q := u.Query()
q.Set("sslmode", ssl)
u.RawQuery = q.Encode()
return u.String()
}
func applyEnv(cfg *Config) {
if v := os.Getenv("HTTP_ADDR"); v != "" {
cfg.HTTPAddr = v
}
if v := os.Getenv("DATABASE_URL"); v != "" {
cfg.DatabaseURL = v
}
if v := os.Getenv("APP_ENV"); v != "" {
cfg.AppEnv = v
}
if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
cfg.DeepSeek.APIKey = v
}
if v := os.Getenv("DEEPSEEK_BASE_URL"); v != "" {
cfg.DeepSeek.BaseURL = strings.TrimRight(v, "/")
}
if v := os.Getenv("DEEPSEEK_MODEL"); v != "" {
cfg.DeepSeek.Model = v
}
if v := os.Getenv("DEEPSEEK_TIMEOUT_SEC"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.DeepSeek.TimeoutSec = n
}
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
// Enabled reports whether DeepSeek can be called.
func (d DeepSeekConfig) Enabled() bool {
return strings.TrimSpace(d.APIKey) != ""
}
+102
View File
@@ -0,0 +1,102 @@
// Package explore provides the L1→L3 explore catalog.
package explore
// Item is a leaf tool in the catalog.
type Item struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Path string `json:"path"`
Icon string `json:"icon"`
Tone string `json:"tone"`
Badge string `json:"badge,omitempty"`
}
// Category is an L1 explore bucket.
type Category struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Tone string `json:"tone"`
Items []Item `json:"items"`
}
// Catalog returns the full explore tree (deterministic, no DB).
// Order: 四核心优先,量表后置。
func Catalog() []Category {
return []Category{
{
Key: "decode", Title: "愈心解码", Description: "一个生日,读懂性格与节奏",
Icon: "△", Tone: "gold",
Items: []Item{
{Key: "portrait", Title: "愈心解码", Description: "生日生成性格解码报告", Path: "/portrait", Icon: "△", Tone: "gold", Badge: "热"},
},
},
{
Key: "star", Title: "星座", Description: "排盘 · 运势 · 合盘",
Icon: "✦", Tone: "night",
Items: []Item{
{Key: "star-report", Title: "本命排盘", Description: "圆形星盘 / 宫位 / 相位 / 运势", Path: "/star", Icon: "✦", Tone: "night", Badge: "热"},
{Key: "star-synastry", Title: "合盘", Description: "比较盘与恋爱/友情/婚姻指数", Path: "/synastry", Icon: "✧", Tone: "night", Badge: "AI"},
},
},
{
Key: "relation", Title: "人格匹配", Description: "双人风格对照 · 相处说明书",
Icon: "♡", Tone: "rose",
Items: []Item{
{Key: "relation-insight", Title: "人格匹配", Description: "行为风格对照与相处建议", Path: "/relation", Icon: "♡", Tone: "rose", Badge: "热"},
},
},
{
Key: "rhythm", Title: "身心节律", Description: "五行倾向与生活节奏建议",
Icon: "☯", Tone: "green",
Items: []Item{
{Key: "rhythm-report", Title: "身心节律报告", Description: "体质倾向与生活建议", Path: "/rhythm", Icon: "☯", Tone: "green"},
{Key: "companion-term", Title: "今日节气陪伴", Description: "季节生活小建议", Path: "/companion", Icon: "♡", Tone: "green"},
},
},
{
Key: "cards", Title: "意象卡片", Description: "投射反思,整理当下感受",
Icon: "◈", Tone: "teal",
Items: []Item{
{Key: "image-cards", Title: "抽取意象卡片", Description: "单卡或三卡组合练习", Path: "/cards", Icon: "◈", Tone: "teal"},
},
},
{
Key: "growth", Title: "情绪与成长", Description: "心情轨迹、成长计划与问答",
Icon: "○", Tone: "green",
Items: []Item{
{Key: "mood-trail", Title: "心情轨迹", Description: "近七日心情回顾", Path: "/companion", Icon: "○", Tone: "green"},
{Key: "growth-plan", Title: "成长计划", Description: "小目标与每日打卡", Path: "/growth-plan", Icon: "▽", Tone: "teal"},
{Key: "ask", Title: "AI 成长助手", Description: "结合档案整理感受", Path: "/ask", Icon: "◎", Tone: "gold"},
},
},
{
Key: "tests", Title: "更多测评", Description: "轻量量表(次要入口)",
Icon: "◆", Tone: "blue",
Items: []Item{
{Key: "mbti-lite", Title: "人格类型探索", Description: "能量与决策偏好(轻量)", Path: "/scales/mbti-lite", Icon: "◆", Tone: "blue"},
{Key: "enneagram-lite", Title: "动机模式探索", Description: "内在动机九型向轻测", Path: "/scales/enneagram-lite", Icon: "⑨", Tone: "purple"},
{Key: "bigfive-lite", Title: "性格五维探索", Description: "稳定特质速览", Path: "/scales/bigfive-lite", Icon: "◈", Tone: "teal"},
{Key: "love-style", Title: "亲密互动探索", Description: "亲密关系中的互动偏好", Path: "/scales/love-style", Icon: "♡", Tone: "rose"},
{Key: "eq-lite", Title: "情绪觉察探索", Description: "识别与调节情绪的习惯", Path: "/scales/eq-lite", Icon: "♥", Tone: "pink"},
{Key: "stress-index", Title: "压力负荷探索", Description: "近期压力与恢复方式", Path: "/scales/stress-index", Icon: "☯", Tone: "green"},
{Key: "career-interest", Title: "职业兴趣探索", Description: "工作动力与环境偏好", Path: "/scales/career-interest", Icon: "◎", Tone: "gold"},
{Key: "communication-style", Title: "沟通方式探索", Description: "表达与倾听偏好", Path: "/scales/communication-style", Icon: "◆", Tone: "blue"},
{Key: "emotion-pattern", Title: "情绪模式探索", Description: "情绪起伏与自我照顾", Path: "/scales/emotion-pattern", Icon: "♥", Tone: "pink"},
},
},
}
}
// CategoryByKey returns one category or nil.
func CategoryByKey(key string) *Category {
for _, c := range Catalog() {
if c.Key == key {
cp := c
return &cp
}
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package explore
import (
"strings"
"testing"
)
func TestCatalogShape(t *testing.T) {
cats := Catalog()
if len(cats) < 6 {
t.Fatalf("want ≥6 L1 categories, got %d", len(cats))
}
keys := map[string]bool{}
for _, c := range cats {
keys[c.Key] = true
if len(c.Items) < 1 {
t.Fatalf("category %s empty", c.Key)
}
for _, it := range c.Items {
if it.Path == "" || !strings.HasPrefix(it.Path, "/") {
t.Fatalf("bad path %#v", it)
}
}
}
for _, k := range []string{"decode", "star", "relation", "tests", "rhythm", "cards", "growth"} {
if !keys[k] {
t.Fatalf("missing category %s", k)
}
}
// 四核心应排在量表之前
if indexOf(cats, "tests") < indexOf(cats, "star") {
t.Fatal("tests should come after star")
}
}
func indexOf(cats []Category, key string) int {
for i, c := range cats {
if c.Key == key {
return i
}
}
return -1
}
func TestLexiconHardBanOnly(t *testing.T) {
blob := ""
for _, c := range Catalog() {
blob += c.Title + c.Description
for _, it := range c.Items {
blob += it.Title + it.Description
}
}
for _, bad := range []string{"占卜", "算命", "测测"} {
if strings.Contains(blob, bad) {
t.Fatalf("forbidden %q", bad)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// AskHandler exposes AI 成长助手 APIs.
type AskHandler struct {
Svc *asksvc.Service
}
// Register mounts ask routes.
func (h *AskHandler) Register(rg *gin.RouterGroup) {
rg.GET("/ask/quota", h.GetQuota)
rg.POST("/ask/threads", h.CreateThread)
rg.GET("/ask/threads/:id/messages", h.ListMessages)
rg.POST("/ask/threads/:id/messages", h.SendMessage)
}
// GetQuota handles GET /ask/quota.
func (h *AskHandler) GetQuota(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
q, err := h.Svc.GetQuota(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, q)
}
// CreateThread handles POST /ask/threads.
func (h *AskHandler) CreateThread(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"`
Scene string `json:"scene"`
}
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
}
th, err := h.Svc.CreateThread(c.Request.Context(), userID, asksvc.CreateThreadInput{
ProfileID: pid, Scene: req.Scene,
})
if err != nil {
response.Fail(c, http.StatusBadRequest, 40010, err.Error())
return
}
response.OK(c, th)
}
// ListMessages handles GET /ask/threads/:id/messages.
func (h *AskHandler) ListMessages(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
tid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
items, err := h.Svc.ListMessages(c.Request.Context(), userID, tid)
if err != nil {
response.Fail(c, http.StatusNotFound, 40410, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// SendMessage handles POST /ask/threads/:id/messages.
func (h *AskHandler) SendMessage(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
tid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
var req struct {
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content)
if err != nil {
if asksvc.IsQuotaExhausted(err) {
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可开通成长会员获取更多次数")
return
}
response.Fail(c, http.StatusBadRequest, 40011, err.Error())
return
}
response.OK(c, out)
}
+77
View File
@@ -0,0 +1,77 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// CompanionHandler exposes solar terms and moods.
type CompanionHandler struct {
Svc *companionsvc.Service
}
// Register mounts companion routes.
func (h *CompanionHandler) Register(rg *gin.RouterGroup) {
rg.GET("/solar-terms/today", h.TodaySolar)
rg.POST("/moods", h.SaveMood)
rg.GET("/moods/today", h.TodayMood)
}
// TodaySolar handles GET /solar-terms/today (auth optional via group).
func (h *CompanionHandler) TodaySolar(c *gin.Context) {
response.OK(c, h.Svc.TodaySolar())
}
// SaveMood handles POST /moods.
func (h *CompanionHandler) SaveMood(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
Score *int `json:"score"`
Note *string `json:"note"`
Day *string `json:"day"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
in := companionsvc.SaveMoodInput{Score: req.Score, Note: req.Note}
if req.Day != nil && *req.Day != "" {
d, err := time.Parse("2006-01-02", *req.Day)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid day")
return
}
in.Day = &d
}
m, err := h.Svc.SaveMood(c.Request.Context(), userID, in)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30010, err.Error())
return
}
response.OK(c, m)
}
// TodayMood handles GET /moods/today.
func (h *CompanionHandler) TodayMood(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
m, err := h.Svc.GetTodayMood(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"mood": m})
}
+34
View File
@@ -0,0 +1,34 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/explore"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ExploreHandler serves the explore catalog.
type ExploreHandler struct{}
// Register mounts explore routes.
func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
rg.GET("/explore/catalog", h.Catalog)
rg.GET("/explore/catalog/:key", h.Category)
}
// Catalog handles GET /explore/catalog.
func (h *ExploreHandler) Catalog(c *gin.Context) {
response.OK(c, gin.H{"categories": explore.Catalog()})
}
// Category handles GET /explore/catalog/:key.
func (h *ExploreHandler) Category(c *gin.Context) {
cat := explore.CategoryByKey(c.Param("key"))
if cat == nil {
response.Fail(c, http.StatusNotFound, 40402, "category not found")
return
}
response.OK(c, cat)
}
+130
View File
@@ -0,0 +1,130 @@
package handler
import (
"net/http"
"strings"
"time"
"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/repository"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// GrowthHandler exposes growth plans and mood trail.
type GrowthHandler struct {
Plans *repository.GrowthRepo
}
// Register mounts growth routes.
func (h *GrowthHandler) Register(rg *gin.RouterGroup) {
rg.GET("/growth/plans", h.ListPlans)
rg.POST("/growth/plans", h.CreatePlan)
rg.POST("/growth/plans/:id/checkin", h.Checkin)
rg.GET("/growth/plans/:id/checkins", h.ListCheckins)
rg.GET("/moods/recent", h.MoodsRecent)
}
// ListPlans handles GET /growth/plans.
func (h *GrowthHandler) ListPlans(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
items, err := h.Plans.ListPlans(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// CreatePlan handles POST /growth/plans.
func (h *GrowthHandler) CreatePlan(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
Title string `json:"title" binding:"required"`
Focus string `json:"focus"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
title := strings.TrimSpace(req.Title)
if title == "" || len([]rune(title)) > 40 {
response.Fail(c, http.StatusBadRequest, 10000, "invalid title")
return
}
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, strings.TrimSpace(req.Focus))
if err != nil {
response.Fail(c, http.StatusBadRequest, 30020, err.Error())
return
}
response.OK(c, p)
}
// Checkin handles POST /growth/plans/:id/checkin.
func (h *GrowthHandler) Checkin(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
var req struct {
Note *string `json:"note"`
}
_ = c.ShouldBindJSON(&req)
out, err := h.Plans.Checkin(c.Request.Context(), userID, pid, time.Now(), req.Note)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30021, err.Error())
return
}
response.OK(c, out)
}
// ListCheckins handles GET /growth/plans/:id/checkins.
func (h *GrowthHandler) ListCheckins(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
items, err := h.Plans.ListCheckinsRecent(c.Request.Context(), userID, pid, 14)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// MoodsRecent handles GET /moods/recent.
func (h *GrowthHandler) MoodsRecent(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
items, err := h.Plans.ListMoodsRecent(c.Request.Context(), userID, 7)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
+81
View File
@@ -0,0 +1,81 @@
package handler
import (
"errors"
"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/repository"
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ImageCardHandler exposes 意象卡片 APIs.
type ImageCardHandler struct {
Svc *imagecardsvc.Service
}
// Register mounts image-card routes.
func (h *ImageCardHandler) Register(rg *gin.RouterGroup) {
rg.GET("/image-cards/scenes", h.Scenes)
rg.GET("/image-cards/quota", h.Quota)
rg.POST("/image-cards/draw", h.Draw)
}
// Scenes handles GET /image-cards/scenes.
func (h *ImageCardHandler) Scenes(c *gin.Context) {
response.OK(c, gin.H{"items": h.Svc.Scenes()})
}
// Quota handles GET /image-cards/quota.
func (h *ImageCardHandler) Quota(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
q, err := h.Svc.Quota(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, q)
}
// Draw handles POST /image-cards/draw.
func (h *ImageCardHandler) Draw(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
Scene string `json:"scene"`
ProfileID string `json:"profile_id" binding:"required"`
Depth bool `json:"depth"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
pid, err := uuid.Parse(req.ProfileID)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id")
return
}
out, err := h.Svc.Draw(c.Request.Context(), userID, imagecardsvc.DrawInput{
Scene: req.Scene, ProfileID: pid, Depth: req.Depth,
})
if err != nil {
if errors.Is(err, repository.ErrQuotaExhausted) {
response.Fail(c, http.StatusPaymentRequired, 40201, err.Error())
return
}
response.Fail(c, http.StatusBadRequest, 30011, err.Error())
return
}
response.OK(c, out)
}
+72 -1
View File
@@ -5,6 +5,7 @@ import (
"time"
"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/profile"
@@ -20,6 +21,8 @@ type ProfileHandler struct {
func (h *ProfileHandler) Register(rg *gin.RouterGroup) {
rg.GET("/profiles", h.List)
rg.POST("/profiles", h.Create)
rg.PATCH("/profiles/:id", h.Update)
rg.DELETE("/profiles/:id", h.Delete)
}
type createProfileReq struct {
@@ -27,6 +30,8 @@ type createProfileReq struct {
DisplayName string `json:"display_name"`
BirthDate string `json:"birth_date" binding:"required"`
RelationType *string `json:"relation_type"`
BirthTime *string `json:"birth_time"`
BirthPlace *string `json:"birth_place"`
}
// Create handles POST /profiles.
@@ -47,7 +52,8 @@ func (h *ProfileHandler) Create(c *gin.Context) {
return
}
p, err := h.Svc.Create(c.Request.Context(), userID, profile.CreateInput{
Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType,
Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth,
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
})
if err != nil {
response.Fail(c, http.StatusBadRequest, 30001, err.Error())
@@ -70,3 +76,68 @@ func (h *ProfileHandler) List(c *gin.Context) {
}
response.OK(c, gin.H{"items": list})
}
// Update handles PATCH /profiles/:id.
func (h *ProfileHandler) Update(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
var req struct {
DisplayName string `json:"display_name"`
BirthDate string `json:"birth_date"`
RelationType *string `json:"relation_type"`
BirthTime *string `json:"birth_time"`
BirthPlace *string `json:"birth_place"`
GeoLat *float64 `json:"geo_lat"`
GeoLng *float64 `json:"geo_lng"`
GeoVisible *bool `json:"geo_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
var birth time.Time
if req.BirthDate != "" {
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.Update(c.Request.Context(), userID, pid, profile.UpdateInput{
DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType,
BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible,
})
if err != nil {
response.Fail(c, http.StatusNotFound, 40401, err.Error())
return
}
response.OK(c, p)
}
// Delete handles DELETE /profiles/:id.
func (h *ProfileHandler) Delete(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
if err := h.Svc.Delete(c.Request.Context(), userID, pid); err != nil {
response.Fail(c, http.StatusNotFound, 40401, err.Error())
return
}
response.OK(c, gin.H{"deleted": true})
}
+137
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -19,7 +20,12 @@ type ReportHandler struct {
// Register mounts report/commerce routes.
func (h *ReportHandler) Register(rg *gin.RouterGroup) {
rg.POST("/reports/portrait", h.CreatePortrait)
rg.POST("/reports/star", h.CreateStar)
rg.POST("/reports/synastry", h.CreateSynastry)
rg.POST("/reports/rhythm", h.CreateRhythm)
rg.GET("/reports", h.List)
rg.GET("/reports/:id", h.Get)
rg.GET("/membership/me", h.GetMembership)
rg.POST("/orders", h.CreateOrder)
rg.POST("/orders/:id/pay-mock", h.PayMock)
}
@@ -51,6 +57,122 @@ func (h *ReportHandler) CreatePortrait(c *gin.Context) {
response.OK(c, rep)
}
// CreateStar handles POST /reports/star (星象性格).
func (h *ReportHandler) CreateStar(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.CreateStar(c.Request.Context(), userID, pid)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}
// CreateSynastry handles POST /reports/synastry (五主盘 + 推运合盘).
func (h *ReportHandler) CreateSynastry(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
ProfileIDA string `json:"profile_id_a" binding:"required"`
ProfileIDB string `json:"profile_id_b" binding:"required"`
AsOf *string `json:"as_of"` // YYYY-MM-DD optional
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
aid, err := uuid.Parse(req.ProfileIDA)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id_a")
return
}
bid, err := uuid.Parse(req.ProfileIDB)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id_b")
return
}
if aid == bid {
response.Fail(c, http.StatusBadRequest, 10000, "需要两个不同档案")
return
}
var asOfPtr *time.Time
if req.AsOf != nil && *req.AsOf != "" {
t, err := time.ParseInLocation("2006-01-02", *req.AsOf, time.FixedZone("CST", 8*3600))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid as_of")
return
}
asOfPtr = &t
}
rep, err := h.Svc.CreateSynastry(c.Request.Context(), userID, aid, bid, asOfPtr)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}
// CreateRhythm handles POST /reports/rhythm (身心节律) — wired when service ready.
func (h *ReportHandler) CreateRhythm(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.CreateRhythm(c.Request.Context(), userID, pid)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}
// List handles GET /reports.
func (h *ReportHandler) List(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
items, err := h.Svc.List(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// Get handles GET /reports/:id.
func (h *ReportHandler) Get(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
@@ -71,6 +193,21 @@ func (h *ReportHandler) Get(c *gin.Context) {
response.OK(c, rep)
}
// GetMembership handles GET /membership/me.
func (h *ReportHandler) GetMembership(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
me, err := h.Svc.GetMembership(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, me)
}
// CreateOrder handles POST /orders.
func (h *ReportHandler) CreateOrder(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
+123
View File
@@ -0,0 +1,123 @@
package handler
import (
"net/http"
"strconv"
"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"
)
// SynastryHandler exposes nearby + invite social APIs.
type SynastryHandler struct {
Svc *report.Service
}
// Register mounts synastry social routes.
func (h *SynastryHandler) Register(rg *gin.RouterGroup) {
rg.GET("/synastry/nearby", h.Nearby)
rg.POST("/synastry/invites", h.CreateInvite)
rg.GET("/synastry/invites/:token", h.GetInvite)
rg.POST("/synastry/invites/:token/accept", h.AcceptInvite)
}
// Nearby handles GET /synastry/nearby?lat=&lng=&radius_km=
func (h *SynastryHandler) Nearby(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
lat, err1 := strconv.ParseFloat(c.Query("lat"), 64)
lng, err2 := strconv.ParseFloat(c.Query("lng"), 64)
if err1 != nil || err2 != nil {
response.Fail(c, http.StatusBadRequest, 10000, "lat/lng required")
return
}
radius := 50.0
if v := c.Query("radius_km"); v != "" {
if r, err := strconv.ParseFloat(v, 64); err == nil {
radius = r
}
}
items, err := h.Svc.Nearby(c.Request.Context(), userID, lat, lng, radius)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// CreateInvite handles POST /synastry/invites
func (h *SynastryHandler) CreateInvite(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
}
inv, err := h.Svc.CreateInvite(c.Request.Context(), userID, pid)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, gin.H{
"token": inv.Token,
"expires_at": inv.ExpiresAt,
"path": "/synastry/invite/" + inv.Token,
})
}
// GetInvite handles GET /synastry/invites/:token
func (h *SynastryHandler) GetInvite(c *gin.Context) {
if _, ok := middleware.UserIDFromContext(c); !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
meta, err := h.Svc.GetInvite(c.Request.Context(), c.Param("token"))
if err != nil {
response.Fail(c, http.StatusNotFound, 40401, err.Error())
return
}
response.OK(c, meta)
}
// AcceptInvite handles POST /synastry/invites/:token/accept
func (h *SynastryHandler) AcceptInvite(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
DisplayName string `json:"display_name"`
BirthDate string `json:"birth_date" binding:"required"`
BirthTime *string `json:"birth_time"`
BirthPlace *string `json:"birth_place"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
rep, err := h.Svc.AcceptInvite(c.Request.Context(), userID, c.Param("token"), req.DisplayName, req.BirthDate, req.BirthTime, req.BirthPlace)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}
+80
View File
@@ -0,0 +1,80 @@
// Package httpserver wires HTTP routes for the 愈心谷 API.
package httpserver
import (
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
"github.com/yuxingu/digital-psychology/apps/api/internal/handler"
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"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/ask"
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
"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/internal/service/scale"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// NewRouter builds the Gin engine with all /api/v1 routes.
func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
profileRepo := &repository.ProfileRepo{Pool: pool}
reportRepo := &repository.ReportRepo{Pool: pool}
relationRepo := &repository.RelationRepo{Pool: pool}
askRepo := &repository.AskRepo{Pool: pool}
var llm *deepseek.Client
if cfg.DeepSeek.Enabled() {
llm = deepseek.New(cfg.DeepSeek)
}
profileSvc := &profile.Service{Repo: profileRepo}
reportSvc := &report.Service{
Profiles: profileRepo,
Reports: reportRepo,
Invites: &repository.SynastryInviteRepo{Pool: pool},
}
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm}
companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}}
imageCardSvc := &imagecardsvc.Service{
Profiles: profileRepo,
Reports: reportRepo,
Quotas: &repository.ImageCardRepo{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)
api.GET("/ping", func(c *gin.Context) {
response.OK(c, gin.H{"pong": true})
})
authed := api.Group("")
authed.Use(middleware.DeviceAuth(pool))
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
(&handler.ReportHandler{Svc: reportSvc}).Register(authed)
(&handler.SynastryHandler{Svc: reportSvc}).Register(authed)
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
(&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
(&handler.AskHandler{Svc: askSvc}).Register(authed)
(&handler.CompanionHandler{Svc: companionSvc}).Register(authed)
(&handler.ImageCardHandler{Svc: imageCardSvc}).Register(authed)
(&handler.ExploreHandler{}).Register(authed)
(&handler.GrowthHandler{
Plans: &repository.GrowthRepo{Pool: pool},
}).Register(authed)
return r
}
+220
View File
@@ -0,0 +1,220 @@
// Package imagecard implements 意象卡片 draws (projection / reflection, not tarot UI).
package imagecard
import (
"fmt"
"hash/fnv"
"time"
)
// Card is one exploration card.
type Card struct {
ID string `json:"id"`
Title string `json:"title"`
Imagery string `json:"imagery"`
Prompt string `json:"prompt"`
Tip string `json:"tip"`
}
// DrawResult is API payload for a draw.
type DrawResult struct {
Scene string `json:"scene"`
Cards []Card `json:"cards"`
Summary map[string]any `json:"summary"`
Detail map[string]any `json:"detail"`
QuotaLeft int `json:"quota_left"`
}
var scenes = []string{
"情绪整理", "关系", "选择", "自我",
"工作节奏", "边界", "休息", "表达",
}
// Scenes returns available reflection scenes.
func Scenes() []map[string]string {
out := make([]map[string]string, 0, len(scenes))
for _, s := range scenes {
out = append(out, map[string]string{"key": s, "label": s})
}
return out
}
// deck built once: 12 handcrafted + generated to ≥78.
var deck = buildDeck()
func buildDeck() []Card {
base := []Card{
{"c01", "微光小路", "一条只够一人走过的小径,尽头有一点光。", "此刻你最想靠近的「光」是什么?", "选一个最小行动靠近它,不必一次走完。"},
{"c02", "安静的杯子", "桌上杯子里的水是温的,蒸汽慢慢散开。", "你最近在为谁/什么「保温」?有没有过热?", "今天给自己倒一杯水,只为自己停两分钟。"},
{"c03", "未拆的信", "信封边缘有点磨损,还没打开。", "有哪句话你一直想说却还没说?", "写下来即可,不一定立刻发出。"},
{"c04", "桥与河", "桥稳稳跨过河,水流在下面经过。", "你现在更需要「过去」还是「停在岸上感受」?", "允许自己选一边,并告诉相关的人。"},
{"c05", "收拾桌面", "杂物被归类,只留一件重要的东西在中间。", "若只能保留一件「最重要」,会是什么?", "本周减少一件消耗你的杂事。"},
{"c06", "窗边呼吸", "窗外有风,窗帘轻轻动。", "你的呼吸是浅还是深?身体哪里最紧?", "做 4 次慢呼吸,肩再放松一点。"},
{"c07", "同行的影子", "两个人影并排,步伐不完全一致。", "关系里你希望对方怎样配合你的节奏?", "用一句具体请求代替抱怨。"},
{"c08", "种子与土", "土里有一颗刚发芽的种子。", "你正在酝酿、还没被人看见的是什么?", "给它一点时间,并做一个保护边界。"},
{"c09", "地图折痕", "地图被折过很多次,路线仍可辨认。", "过去哪些弯路其实教了你方法?", "写下一条「下次可以更早用的经验」。"},
{"c10", "钥匙串", "一串钥匙里有一把还没用过。", "你手头有哪项资源/能力还没启用?", "本周试用一次那个「备用钥匙」。"},
{"c11", "退潮沙滩", "潮水退去,露出一些小贝壳。", "热闹过后,你发现了什么真实感受?", "把感受写三句,不评判对错。"},
{"c12", "灯塔节奏", "灯塔规律地亮—灭—亮。", "你的生活里什么节奏让你安心?", "把那个节奏固定进日程一格。"},
}
themes := []struct{ title, imagery, prompt, tip string }{
{"山径转弯", "山路在雾里拐了一个弯,远处仍可见脚印。", "你正在避开的弯是什么?", "允许自己慢半步,再决定方向。"},
{"雨后青石", "雨停了,石板反光,空气很干净。", "刚过去的「雨」带走了什么?", "写下三件你想留下的。"},
{"未完成的画", "画布只涂了一角,颜料还湿着。", "哪件事值得继续涂,而不是重开一张?", "今天只加一笔就够。"},
{"旧毛衣", "袖口有点起球,却仍然暖和。", "你生活里哪件「旧物」仍在保护你?", "对它说一句感谢。"},
{"电梯门", "电梯门开了又关,楼层数字在跳。", "你卡在哪一层不愿下去或上去?", "选一个小出口先离开卡点。"},
{"夜班台灯", "台灯只照亮桌面一小块。", "你需要把注意力收窄到哪里?", "设 25 分钟专注,然后休息。"},
{"空座位", "长椅空着一半,另一半有阳光。", "你更需要坐下还是让出位置?", "今天做一个明确的「要/不要」。"},
{"漂流瓶", "瓶子在水里轻轻晃,纸条卷着。", "你想让谁「偶然」读到你的真心?", "写给自己一封短信即可。"},
{"鞋带", "鞋带松了,还在走。", "哪里需要先停下来系紧?", "处理一件基础小事再赶路。"},
{"回声廊", "走廊很长,声音会折返。", "你说出口的话,回来时变成了什么?", "少说一句评判,多问一句好奇。"},
{"晨雾车站", "站台有雾,车还没到。", "等待里你在消耗还是在准备?", "用等待做一件小事(喝水/伸展)。"},
{"书架空隙", "两本书之间留了一指宽空隙。", "你生活里需要留白的是哪一块?", "从日程删掉一个非必要。"},
{"冷水龙头", "水先凉后热,需要等一会儿。", "你对谁/什么事太急了?", "给过程多 10 分钟缓冲。"},
{"风筝线", "线在手里,风筝在高处晃。", "你抓得太紧的是什么?", "刻意松开一点控制,观察结果。"},
{"烛火", "火焰小而稳,周围很暗。", "什么能成为你今晚的小光源?", "做一件温暖且短的事。"},
{"拼图缺角", "图案几乎成型,只差一块。", "缺的那块真的在外面,还是你还没承认?", "列出「已有」而不是「没有」。"},
{"旧车票", "票根折痕深,日期已过。", "哪段旅程可以温柔结束了?", "做一个小小的告别仪式。"},
{"晾衣绳", "衣服在风里慢慢干。", "什么事其实只需要时间,不需要用力?", "今天不催自己一次。"},
{"镜面湖", "湖面几乎无波,倒映天空。", "你害怕看到的自己是哪一面?", "对镜写下三个不评判的观察。"},
{"登山杖", "杖尖戳进土里,借力往上。", "你愿意向谁借一点力?", "发出一个具体求助。"},
{"暖手宝", "掌心慢慢回温。", "谁/什么在给你持续的暖?", "今天主动回馈一点点暖。"},
{"十字路口灯", "红灯停,绿灯行,黄灯提醒。", "你现在更需要停、行,还是减速?", "按信号选一个动作执行。"},
{"背包重量", "肩带勒出印子。", "包里哪样可以先拿出来?", "卸下一件心理负担并告诉自己。"},
{"夜路灯", "一盏盏灯把路分段照亮。", "你下一步只需要照亮多远?", "只计划明天上午即可。"},
{"陶罐裂缝", "裂缝里透出一点光。", "你的「不完美」里藏了什么资源?", "把一个缺点改写成可用特质。"},
{"梯田", "一层一层往上,水流向下。", "你的成长是哪一层该浇水?", "本周只推进一层目标。"},
{"耳机静音", "世界声音被隔开一点。", "你需要隔开什么噪音?", "设定一段无信息时段。"},
{"折纸船", "小船在水盆里打转。", "你把安全感放在哪里?", "做一个可控的小实验。"},
{"墙洞光", "光从窄缝进来,依然够用。", "有限条件下你仍能做什么?", "用现有资源完成一件小事。"},
{"沙漏", "沙子安静落下。", "你在跟时间较劲什么?", "接受「够好」的标准一次。"},
{"回廊座", "走廊尽头有一张椅子。", "你允许自己坐下休息了吗?", "安排 15 分钟真正休息。"},
{"雾中灯塔", "雾浓,灯仍按节奏闪。", "不确定时什么原则仍不变?", "写下你的一条底线。"},
{"青苔石", "石头潮湿,青苔慢慢长。", "哪段关系需要慢养而不是快修?", "降低频率,提高质量。"},
{"晾晒日光", "被子在阳光里松软。", "什么情绪需要「拿出去晒晒」?", "和信任的人聊 10 分钟。"},
{"圆规", "圆心固定,半径可调。", "你的边界圆心在哪里?", "明确一个「可以/不可以」。"},
{"夜航图", "星点标出航线。", "你参考的「星」是谁的标准?", "换成自己的三条原则。"},
{"竹筒饭", "竹香与米香混在一起。", "简单事物里你忽略了什么满足?", "吃一顿专注的饭。"},
{"软垫", "落地时被接住。", "跌倒时谁/什么接住了你?", "写感谢,或成为别人的软垫一次。"},
{"窗格影子", "格子把光切成块。", "你把生活切得太碎了吗?", "合并两件可一起做的事。"},
{"河石", "棱角被水磨圆。", "冲突磨掉了你的什么?留下了什么?", "保留圆润,也保留核心硬度。"},
{"草稿本", "涂改很多,仍翻得开。", "哪份「草稿」其实已经能见人?", "发布一个 70 分版本。"},
{"门铃", "轻按一下就会有回应。", "你在等谁来按铃?还是该自己开门?", "主动发起一次连结。"},
{"冬青", "天冷仍绿。", "什么习惯在难时仍支撑你?", "把那习惯写进日程。"},
{"秋千", "来回摆,最高点短暂。", "你在追峰值还是稳态?", "选择稳态的一小步。"},
{"墨点", "一滴墨在宣纸上晕开。", "影响正在扩大的是什么?", "及时止损或顺势引导。"},
{"绳结", "结打紧了,解需要耐心。", "哪个结值得慢慢解?", "今天只解一层。"},
{"麦浪", "风过,一片起伏。", "集体节奏里你站在哪?", "允许与主流差半拍。"},
{"星尘罐", "罐子装不住光,却闪着。", "你收藏的希望是什么?", "拿出来看一眼,再盖上。"},
{"石阶", "一级一级,并不陡。", "你把台阶看高了吗?", "只上今天这一级。"},
{"暖汤", "热气模糊了眼镜。", "谁需要一碗「暖」?", "做一件具体的照顾。"},
{"风铃", "风来才响。", "什么条件一到你就会行动?", "把条件改成更小的触发。"},
{"书签", "停在未读完的那页。", "你暂停的故事要不要继续?", "读完两页或正式合上。"},
{"露珠", "很快会消失,却折射天空。", "短暂却珍贵的片刻是什么?", "用心经历,不必抓住。"},
{"柴门", "门不华丽,却通向家。", "「回家」对你意味着什么?", "安排一次真正放松的回家感。"},
{"远山", "看得到,走得到需要天。", "远目标如何拆成今日可见的一步?", "写下「今天可见的山脚」。"},
{"井绳", "要用力,也要节奏。", "你在哪用力过猛?", "改成间歇用力。"},
{"纸飞机", "飞不远,但方向是自己的。", "小实验可以朝哪飞?", "发出一个低成本试探。"},
{"苔径", "湿滑,需放慢。", "哪里需要防滑措施?", "加一个缓冲或备份。"},
{"铜铃", "声音清脆后很快静。", "提醒响过,你听见了吗?", "把提醒变成一个行动。"},
{"白瓷碗", "空着时也完整。", "空不是缺失,可能是准备。", "留出空白时段不被填满。"},
{"夜雨窗", "雨点连成线。", "情绪连成片时你怎么分段?", "用呼吸把情绪切成小节。"},
{"藤椅", "坐下就陷进去一点。", "你允许自己「陷进」休息吗?", "无罪恶感地休息 20 分钟。"},
{"路牌", "箭头指向两个镇。", "信息不够时如何选?", "选可逆的那条先走。"},
{"炭火", "表面灰,内里仍热。", "表面平静下还热着的是什么?", "找安全出口表达它。"},
{"蒲公英", "风一吹就出发。", "你准备好松开了吗?", "松开一个过时期待。"},
{"石桥栏", "扶手冰凉但可靠。", "你的可靠支持是什么?", "主动靠一下支持系统。"},
}
out := append([]Card{}, base...)
for i, th := range themes {
out = append(out, Card{
ID: fmt.Sprintf("c%02d", len(base)+i+1),
Title: th.title,
Imagery: th.imagery,
Prompt: th.prompt,
Tip: th.tip,
})
}
// pad to at least 78 with deterministic variants
for len(out) < 78 {
i := len(out)
src := base[i%len(base)]
out = append(out, Card{
ID: fmt.Sprintf("c%02d", i+1),
Title: fmt.Sprintf("%s·续", src.Title),
Imagery: src.Imagery + " 光线又柔和了一点。",
Prompt: src.Prompt,
Tip: src.Tip,
})
}
return out[:78]
}
// Draw picks cards deterministically from userID+scene+day for free single, or 3 for deep.
func Draw(userKey, scene string, deep bool, now time.Time) DrawResult {
if scene == "" {
scene = scenes[0]
}
n := 1
if deep {
n = 3
}
day := now.UTC().Format("2006-01-02")
idxs := pickIndexes(userKey+"|"+scene+"|"+day, n, len(deck))
cards := make([]Card, 0, n)
for _, i := range idxs {
cards = append(cards, deck[i])
}
summary := map[string]any{
"title": "意象卡片·探索",
"headline": fmt.Sprintf("场景「%s」· %s", scene, cards[0].Title),
"one_liner": cards[0].Imagery,
"overview": fmt.Sprintf("反思:%s", cards[0].Prompt),
"life_tip": cards[0].Tip,
"keywords": []string{"意象卡片", scene, cards[0].Title},
"scene": scene,
"card_ids": idsOf(cards),
}
var detail map[string]any
if deep && len(cards) >= 3 {
detail = map[string]any{
"title": "意象卡片·组合反思",
"sections": []map[string]any{
{"title": cards[0].Title, "body": cards[0].Imagery + " " + cards[0].Prompt, "bullets": []string{cards[0].Tip}},
{"title": cards[1].Title, "body": cards[1].Imagery + " " + cards[1].Prompt, "bullets": []string{cards[1].Tip}},
{"title": cards[2].Title, "body": cards[2].Imagery + " " + cards[2].Prompt, "bullets": []string{cards[2].Tip}},
{"title": "组合练习", "body": "把三张卡连成一个小故事:发生了什么、你感受到什么、下一步一小步是什么。写 5 行即可。", "bullets": []string{"不评判对错", "只选一个可执行下一步", "需要时可去问答继续聊"}},
},
"conversation_scripts": []string{"我抽到的意象让我想到……", "我现在需要的是……"},
"faq": []map[string]string{
{"q": "这是在预测未来吗?", "a": "不是。意象卡片用于投射与自我反思,不判定好坏,也不预测未来。"},
},
}
}
return DrawResult{Scene: scene, Cards: cards, Summary: summary, Detail: detail}
}
func idsOf(cards []Card) []string {
out := make([]string, len(cards))
for i, c := range cards {
out[i] = c.ID
}
return out
}
func pickIndexes(seed string, n, mod int) []int {
h := fnv.New32a()
_, _ = h.Write([]byte(seed))
start := int(h.Sum32() % uint32(mod))
out := make([]int, 0, n)
seen := map[int]bool{}
for i := 0; len(out) < n && i < mod*2; i++ {
idx := (start + i*3) % mod
if seen[idx] {
continue
}
seen[idx] = true
out = append(out, idx)
}
return out
}
+36
View File
@@ -0,0 +1,36 @@
package imagecard
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestDeckSize(t *testing.T) {
if len(deck) < 78 {
t.Fatalf("want ≥78 cards, got %d", len(deck))
}
if len(scenes) < 8 {
t.Fatalf("want ≥8 scenes, got %d", len(scenes))
}
}
func TestDrawLexicon(t *testing.T) {
out := Draw("u1", "情绪整理", true, time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC))
if len(out.Cards) != 3 {
t.Fatalf("want 3 cards, got %d", len(out.Cards))
}
raw, _ := json.Marshal(out)
for _, bad := range []string{"算命", "占卜"} {
if strings.Contains(string(raw), bad) {
t.Fatalf("forbidden %q", bad)
}
}
}
func TestScenes(t *testing.T) {
if len(Scenes()) < 3 {
t.Fatal("too few scenes")
}
}
@@ -0,0 +1,45 @@
package integration_test
import (
"net/http"
"testing"
)
func TestExploreCatalog(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog", nil, key)
data := decodeData[map[string]any](t, env.Data)
cats, ok := data["categories"].([]any)
if !ok || len(cats) < 6 {
t.Fatalf("categories=%#v", data["categories"])
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog/tests", nil, key)
cat := decodeData[map[string]any](t, env.Data)
items, _ := cat["items"].([]any)
if len(items) < 5 {
t.Fatalf("tests items=%d", len(items))
}
}
func TestGrowthPlanCheckin(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodPost, "/api/v1/growth/plans", map[string]any{
"title": "每晚早睡", "focus": "保护睡眠",
}, key)
plan := decodeData[map[string]any](t, env.Data)
id, _ := plan["id"].(string)
if id == "" {
t.Fatal("missing plan id")
}
_, key = doJSON(t, r, http.MethodPost, "/api/v1/growth/plans/"+id+"/checkin", map[string]any{
"note": "做到了",
}, key)
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/growth/plans/"+id+"/checkins", nil, key)
out := decodeData[map[string]any](t, env.Data)
items, _ := out["items"].([]any)
if len(items) < 1 {
t.Fatal("expected checkin")
}
}
@@ -0,0 +1,308 @@
package integration_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"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/httpserver"
)
type envelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
func setupAPI(t *testing.T) (*gin.Engine, string) {
t.Helper()
gin.SetMode(gin.TestMode)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
cfg := config.Load()
pool, err := db.Connect(ctx, cfg.DatabaseURL)
if err != nil {
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
}
t.Cleanup(pool.Close)
migDir := filepath.Join("..", "..", "migrations")
if err := db.Migrate(ctx, pool, migDir); err != nil {
t.Fatalf("migrate: %v", err)
}
return httpserver.NewRouter(pool, cfg), ""
}
func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceKey string) (envelope, string) {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encode: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
if deviceKey != "" {
req.Header.Set("X-Device-Key", deviceKey)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code >= 500 {
t.Fatalf("%s %s → HTTP %d: %s", method, path, w.Code, w.Body.String())
}
var env envelope
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
}
if env.Code != 0 {
t.Fatalf("%s %s → code=%d message=%s", method, path, env.Code, env.Message)
}
key := w.Header().Get("X-Device-Key")
if key == "" {
key = deviceKey
}
return env, key
}
func decodeData[T any](t *testing.T, raw json.RawMessage) T {
t.Helper()
var v T
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatalf("decode data: %v raw=%s", err, string(raw))
}
return v
}
// Flow 1: create profile → portrait → deep_access mock → detail visible
func TestFlowPortraitDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
}, key)
profile := decodeData[map[string]any](t, env.Data)
profileID, _ := profile["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{
"profile_id": profileID,
}, key)
rep := decodeData[map[string]any](t, env.Data)
reportID, _ := rep["id"].(string)
if rep["has_deep_access"] == true {
t.Fatal("expected gated detail before pay")
}
if rep["detail"] != nil {
t.Fatal("detail should be nil before deep access")
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
"kind": "deep_access", "report_id": reportID,
}, key)
order := decodeData[map[string]any](t, env.Data)
orderID, _ := order["order_id"].(string)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
unlocked := decodeData[map[string]any](t, env.Data)
if unlocked["has_deep_access"] != true {
t.Fatal("expected has_deep_access after pay")
}
detail, ok := unlocked["detail"].(map[string]any)
if !ok || len(detail) == 0 {
t.Fatalf("expected detail map, got %#v", unlocked["detail"])
}
if detail["behavior_pattern"] == nil || detail["behavior_pattern"] == "" {
t.Fatal("expected behavior_pattern in detail")
}
}
// Flow 2: two profiles → relation insight → deep_access → tips visible
func TestFlowRelationDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1988-03-01", "display_name": "我",
}, key)
a := decodeData[map[string]any](t, env.Data)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "other", "birth_date": "1992-08-20", "display_name": "TA", "relation_type": "partner",
}, key)
b := decodeData[map[string]any](t, env.Data)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/relation/insight", map[string]any{
"profile_a_id": a["id"], "profile_b_id": b["id"],
}, key)
out := decodeData[map[string]any](t, env.Data)
rep, ok := out["report"].(map[string]any)
if !ok {
t.Fatalf("missing report: %#v", out)
}
reportID, _ := rep["id"].(string)
if rep["has_deep_access"] == true {
t.Fatal("expected gated tips before pay")
}
sum, _ := rep["summary"].(map[string]any)
if sum["love_index"] == nil || sum["friend_index"] == nil || sum["marriage_index"] == nil {
t.Fatalf("expected match indices in summary: %#v", sum)
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
"kind": "deep_access", "report_id": reportID,
}, key)
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
unlocked := decodeData[map[string]any](t, env.Data)
if unlocked["has_deep_access"] != true {
t.Fatal("expected deep access")
}
detail, ok := unlocked["detail"].(map[string]any)
if !ok {
t.Fatal("expected detail")
}
comm, _ := detail["communication"].([]any)
if len(comm) == 0 {
t.Fatalf("expected communication tips, detail=%#v", detail)
}
}
// Flow 3: membership mock → entitlement → portrait detail without per-report deep_access
func TestFlowMembershipUnlock(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1995-11-07", "display_name": "我",
}, key)
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{
"profile_id": profileID,
}, key)
reportID := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodGet, "/api/v1/membership/me", nil, key)
me := decodeData[map[string]any](t, env.Data)
if me["active"] == true {
t.Fatal("expected inactive membership before subscribe")
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
"kind": "membership", "plan": "month",
}, key)
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
env, key = doJSON(t, r, http.MethodGet, "/api/v1/membership/me", nil, key)
me = decodeData[map[string]any](t, env.Data)
if me["active"] != true {
t.Fatalf("expected active membership, got %#v", me)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
unlocked := decodeData[map[string]any](t, env.Data)
if unlocked["has_deep_access"] != true {
t.Fatal("membership should unlock report detail")
}
if unlocked["detail"] == nil {
t.Fatal("expected detail via membership")
}
}
// Flow 5: profile update + soft
func TestFlowProfileUpdateDelete(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "other", "birth_date": "1993-04-04", "display_name": "旧名", "relation_type": "friend",
}, key)
id := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodPatch, "/api/v1/profiles/"+id, map[string]any{
"display_name": "新名", "birth_date": "1993-04-05", "relation_type": "partner",
}, key)
updated := decodeData[map[string]any](t, env.Data)
if updated["display_name"] != "新名" {
t.Fatalf("display_name not updated: %#v", updated)
}
_, key = doJSON(t, r, http.MethodDelete, "/api/v1/profiles/"+id, nil, key)
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, key)
items, _ := decodeData[map[string]any](t, env.Data)["items"].([]any)
for _, it := range items {
m := it.(map[string]any)
if m["id"] == id {
t.Fatal("deleted profile still listed")
}
}
}
// Flow 4: profile → ask thread → message → assistant reply + quota
func TestFlowAskThread(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1991-02-14", "display_name": "我",
}, key)
profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodGet, "/api/v1/ask/quota", nil, key)
q0 := decodeData[map[string]any](t, env.Data)
rem0, _ := q0["remaining"].(float64)
if rem0 < 1 {
t.Fatalf("expected free quota, got %#v", q0)
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
"profile_id": profileID, "scene": "self",
}, key)
threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
"content": "我想更了解自己",
}, key)
out := decodeData[map[string]any](t, env.Data)
asst, ok := out["assistant_message"].(map[string]any)
if !ok {
t.Fatalf("missing assistant_message: %#v", out)
}
content, _ := asst["content"].(string)
if content == "" {
t.Fatal("empty assistant reply")
}
q1, ok := out["quota"].(map[string]any)
if !ok {
t.Fatal("missing quota")
}
rem1, _ := q1["remaining"].(float64)
if rem1 != rem0-1 {
t.Fatalf("quota should decrease: before=%v after=%v", rem0, rem1)
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/ask/threads/"+threadID+"/messages", nil, key)
items := decodeData[map[string]any](t, env.Data)["items"].([]any)
if len(items) < 2 {
t.Fatalf("expected user+assistant history, got %d", len(items))
}
}
@@ -0,0 +1,222 @@
package integration_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func doJSONExpect(t *testing.T, r http.Handler, method, path string, body any, deviceKey string, wantCode int) (envelope, string, int) {
t.Helper()
var buf bytes.Buffer
if body != nil {
if err := json.NewEncoder(&buf).Encode(body); err != nil {
t.Fatalf("encode: %v", err)
}
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
if deviceKey != "" {
req.Header.Set("X-Device-Key", deviceKey)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code >= 500 {
t.Fatalf("%s %s → HTTP %d: %s", method, path, w.Code, w.Body.String())
}
var env envelope
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
}
if env.Code != wantCode {
t.Fatalf("%s %s → code=%d want=%d message=%s body=%s", method, path, env.Code, wantCode, env.Message, w.Body.String())
}
key := w.Header().Get("X-Device-Key")
if key == "" {
key = deviceKey
}
return env, key, w.Code
}
func assertNoForbidden(t *testing.T, blob string) {
t.Helper()
for _, bad := range []string{"算命"} {
if strings.Contains(blob, bad) {
t.Fatalf("forbidden lexicon %q in response", bad)
}
}
}
func createSelfProfile(t *testing.T, r http.Handler, birth, key string) (profileID, newKey string) {
t.Helper()
env, newKey := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": birth, "display_name": "我",
}, key)
return decodeData[map[string]any](t, env.Data)["id"].(string), newKey
}
// Flow: star report → gated detail → mock deep unlock
func TestFlowStarDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
pid, key := createSelfProfile(t, r, "1983-06-06", "")
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/star", map[string]any{
"profile_id": pid,
}, key)
rep := decodeData[map[string]any](t, env.Data)
assertNoForbidden(t, string(env.Data))
if rep["type"] != "star" {
t.Fatalf("type=%v", rep["type"])
}
if rep["has_deep_access"] == true || rep["detail"] != nil {
t.Fatal("expected gated star detail")
}
sum, _ := rep["summary"].(map[string]any)
if sum["headline"] == nil || sum["headline"] == "" {
t.Fatalf("missing headline: %#v", sum)
}
if sum["fortune"] == nil || sum["planets"] == nil {
t.Fatalf("expected fortune+planets in star summary: %#v", sum)
}
reportID := rep["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
"kind": "deep_access", "report_id": reportID,
}, key)
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
unlocked := decodeData[map[string]any](t, env.Data)
if unlocked["has_deep_access"] != true {
t.Fatal("expected deep after pay")
}
detail, ok := unlocked["detail"].(map[string]any)
if !ok || detail["sections"] == nil {
t.Fatalf("expected sections, got %#v", unlocked["detail"])
}
}
// Flow: rhythm report gated + unlock
func TestFlowRhythmDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
pid, key := createSelfProfile(t, r, "1992-06-08", "")
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/rhythm", map[string]any{
"profile_id": pid,
}, key)
rep := decodeData[map[string]any](t, env.Data)
assertNoForbidden(t, string(env.Data))
if rep["type"] != "rhythm" {
t.Fatalf("type=%v", rep["type"])
}
if rep["detail"] != nil {
t.Fatal("expected gated rhythm detail")
}
reportID := rep["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
"kind": "deep_access", "report_id": reportID,
}, key)
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
unlocked := decodeData[map[string]any](t, env.Data)
if unlocked["has_deep_access"] != true {
t.Fatal("expected deep")
}
if unlocked["detail"] == nil {
t.Fatal("expected detail after unlock")
}
}
// Flow: image card scenes → draw → quota exhaust → depth unlock via mock pay
func TestFlowImageCardQuotaAndDepth(t *testing.T) {
r, _ := setupAPI(t)
pid, key := createSelfProfile(t, r, "1990-01-01", "")
env, key := doJSON(t, r, http.MethodGet, "/api/v1/image-cards/scenes", nil, key)
scenes := decodeData[map[string]any](t, env.Data)
items, _ := scenes["items"].([]any)
if len(items) < 1 {
t.Fatal("expected scenes")
}
env, key = doJSON(t, r, http.MethodGet, "/api/v1/image-cards/quota", nil, key)
q := decodeData[map[string]any](t, env.Data)
if int(q["remaining"].(float64)) < 1 {
t.Fatalf("expected free quota, got %#v", q)
}
var reportID string
for i := 0; i < 3; i++ {
env, key = doJSON(t, r, http.MethodPost, "/api/v1/image-cards/draw", map[string]any{
"profile_id": pid, "scene": "情绪整理", "depth": true,
}, key)
out := decodeData[map[string]any](t, env.Data)
assertNoForbidden(t, string(env.Data))
cards, _ := out["cards"].([]any)
if len(cards) < 1 {
t.Fatalf("draw %d: no cards", i)
}
rep, _ := out["report"].(map[string]any)
reportID, _ = rep["id"].(string)
if rep["has_deep_access"] == true {
t.Fatal("free user should not have deep on draw")
}
}
_, key, httpStatus := doJSONExpect(t, r, http.MethodPost, "/api/v1/image-cards/draw", map[string]any{
"profile_id": pid, "scene": "情绪整理",
}, key, 40201)
if httpStatus != http.StatusPaymentRequired {
t.Fatalf("want HTTP 402, got %d", httpStatus)
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
"kind": "deep_access", "report_id": reportID,
}, key)
orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
_, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/reports/"+reportID, nil, key)
unlocked := decodeData[map[string]any](t, env.Data)
if unlocked["has_deep_access"] != true {
t.Fatal("expected deep on image_card report")
}
if unlocked["type"] != "image_card" {
t.Fatalf("type=%v", unlocked["type"])
}
}
// Flow: solar terms + mood save/read
func TestFlowCompanionMood(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodGet, "/api/v1/solar-terms/today", nil, key)
term := decodeData[map[string]any](t, env.Data)
assertNoForbidden(t, string(env.Data))
if term["name"] == nil || term["tip"] == nil {
t.Fatalf("bad solar term: %#v", term)
}
env, key = doJSON(t, r, http.MethodPost, "/api/v1/moods", map[string]any{
"score": 4, "note": "今天还不错",
}, key)
mood := decodeData[map[string]any](t, env.Data)
if mood["score"].(float64) != 4 {
t.Fatalf("score=%v", mood["score"])
}
env, _ = doJSON(t, r, http.MethodGet, "/api/v1/moods/today", nil, key)
today := decodeData[map[string]any](t, env.Data)
m, ok := today["mood"].(map[string]any)
if !ok || m["score"].(float64) != 4 {
t.Fatalf("today mood=%#v", today)
}
}
@@ -0,0 +1,87 @@
package integration_test
import (
"net/http"
"testing"
)
func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
r, _ := setupAPI(t)
var key string
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
"birth_time": "10:30", "birth_place": "上海",
}, key)
pa := decodeData[map[string]any](t, env.Data)
idA, _ := pa["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "other", "birth_date": "1992-08-20", "display_name": "TA",
"birth_time": "08:00", "birth_place": "北京",
}, key)
pb := decodeData[map[string]any](t, env.Data)
idB, _ := pb["id"].(string)
env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/synastry", map[string]any{
"profile_id_a": idA, "profile_id_b": idB, "as_of": "2026-08-02",
}, key)
rep := decodeData[map[string]any](t, env.Data)
if rep["type"] != "synastry" {
t.Fatalf("type=%v", rep["type"])
}
sum, _ := rep["summary"].(map[string]any)
if sum == nil {
t.Fatal("summary nil")
}
if sum["as_of"] != "2026-08-02" {
t.Fatalf("as_of=%v", sum["as_of"])
}
charts, _ := sum["charts"].(map[string]any)
if charts == nil {
t.Fatal("charts missing")
}
for _, k := range []string{
"compare", "composite", "davison", "marks_me", "marks_other", "overlay",
"composite_progressed", "davison_progressed", "marks_progressed",
} {
if charts[k] == nil {
t.Fatalf("missing charts.%s", k)
}
}
// no deep access → detail stripped
if rep["has_deep_access"] == true {
t.Fatal("expected no deep access")
}
if rep["detail"] != nil {
t.Fatalf("detail should be stripped, got %#v", rep["detail"])
}
// invite flow: second device accepts
env, key = doJSON(t, r, http.MethodPost, "/api/v1/synastry/invites", map[string]any{
"profile_id": idA,
}, key)
inv := decodeData[map[string]any](t, env.Data)
token, _ := inv["token"].(string)
if token == "" {
t.Fatal("empty token")
}
env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
_ = env2
env2, key2 = doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, key2)
meta := decodeData[map[string]any](t, env2.Data)
if meta["host_name"] == nil {
t.Fatal("host_name missing")
}
env2, key2 = doJSON(t, r, http.MethodPost, "/api/v1/synastry/invites/"+token+"/accept", map[string]any{
"display_name": "好友", "birth_date": "1995-03-03",
}, key2)
acc := decodeData[map[string]any](t, env2.Data)
if acc["type"] != "synastry" {
t.Fatalf("accept type=%v", acc["type"])
}
_ = key
_ = key2
}
+118
View File
@@ -0,0 +1,118 @@
// Package deepseek calls DeepSeek OpenAI-compatible Chat Completions API.
package deepseek
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
)
// Message is one chat turn.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Client talks to DeepSeek.
type Client struct {
cfg config.DeepSeekConfig
http *http.Client
}
// New builds a client from config.
func New(cfg config.DeepSeekConfig) *Client {
sec := cfg.TimeoutSec
if sec <= 0 {
sec = 60
}
return &Client{
cfg: cfg,
http: &http.Client{
Timeout: time.Duration(sec) * time.Second,
},
}
}
// Enabled mirrors config.
func (c *Client) Enabled() bool {
return c != nil && c.cfg.Enabled()
}
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type chatResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Chat sends messages and returns assistant text.
func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
if !c.Enabled() {
return "", fmt.Errorf("deepseek api_key not configured")
}
base := c.cfg.BaseURL
if base == "" {
base = "https://api.deepseek.com"
}
model := c.cfg.Model
if model == "" {
model = "deepseek-chat"
}
body, err := json.Marshal(chatRequest{Model: model, Messages: messages, Stream: false})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
res, err := c.http.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 2<<20))
if err != nil {
return "", err
}
var out chatResponse
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("deepseek decode: %w body=%s", err, truncate(string(raw), 200))
}
if out.Error != nil && out.Error.Message != "" {
return "", fmt.Errorf("deepseek: %s", out.Error.Message)
}
if res.StatusCode >= 300 {
return "", fmt.Errorf("deepseek HTTP %d: %s", res.StatusCode, truncate(string(raw), 200))
}
if len(out.Choices) == 0 || strings.TrimSpace(out.Choices[0].Message.Content) == "" {
return "", fmt.Errorf("deepseek empty choices")
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
@@ -0,0 +1,47 @@
package deepseek
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
)
func TestChat_success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Fatalf("auth header: %s", r.Header.Get("Authorization"))
}
var req chatRequest
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Model != "deepseek-chat" {
t.Fatalf("model=%s", req.Model)
}
_ = json.NewEncoder(w).Encode(chatResponse{
Choices: []struct {
Message Message `json:"message"`
}{{Message: Message{Role: "assistant", Content: "你好,我是成长助手"}}},
})
}))
defer srv.Close()
c := New(config.DeepSeekConfig{
APIKey: "test-key", BaseURL: srv.URL, Model: "deepseek-chat", TimeoutSec: 5,
})
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}})
if err != nil {
t.Fatal(err)
}
if out != "你好,我是成长助手" {
t.Fatalf("got %q", out)
}
}
func TestEnabled(t *testing.T) {
if New(config.DeepSeekConfig{}).Enabled() {
t.Fatal("empty key should disable")
}
}
+25
View File
@@ -0,0 +1,25 @@
package model
import (
"time"
"github.com/google/uuid"
)
// AskThread binds a conversation to a profile.
type AskThread struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
ProfileID uuid.UUID `json:"profile_id"`
Scene *string `json:"scene,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AskMessage is one turn in a thread.
type AskMessage struct {
ID uuid.UUID `json:"id"`
ThreadID uuid.UUID `json:"thread_id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
+27
View File
@@ -0,0 +1,27 @@
package model
import (
"time"
"github.com/google/uuid"
)
// GrowthPlan is a small personal growth goal.
type GrowthPlan struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Title string `json:"title"`
Focus string `json:"focus"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// GrowthCheckin is a daily check-in on a plan.
type GrowthCheckin struct {
ID uuid.UUID `json:"id"`
PlanID uuid.UUID `json:"plan_id"`
UserID uuid.UUID `json:"user_id"`
Day string `json:"day"`
Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+17
View File
@@ -0,0 +1,17 @@
package model
import (
"time"
"github.com/google/uuid"
)
// Mood is a daily mood check-in.
type Mood struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Day string `json:"day"`
Score *int `json:"score,omitempty"`
Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
+26 -10
View File
@@ -8,14 +8,30 @@ import (
// 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"`
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"`
GeoLat *float64 `json:"geo_lat,omitempty"`
GeoLng *float64 `json:"geo_lng,omitempty"`
GeoVisible bool `json:"geo_visible"`
CreatedAt time.Time `json:"created_at"`
}
// SynastryInvite is a shareable pair invite.
type SynastryInvite struct {
ID uuid.UUID `json:"id"`
Token string `json:"token"`
HostUserID uuid.UUID `json:"host_user_id"`
HostProfileID uuid.UUID `json:"host_profile_id"`
ExpiresAt time.Time `json:"expires_at"`
GuestUserID *uuid.UUID `json:"guest_user_id,omitempty"`
GuestProfileID *uuid.UUID `json:"guest_profile_id,omitempty"`
ReportID *uuid.UUID `json:"report_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,123 @@
package portrait
type ConstitutionDetail struct {
Primary string
Secondary string
Strong string
Weak string
Desc string
Body string
Tips string
Diseases string
SeasonRisk string
FoodTherapy string
DailyRhythm string
}
var constitutionByElement = map[string]ConstitutionDetail{
"木": {
Primary: "气郁质",
Secondary: "血瘀质",
Strong: "肝",
Weak: "脾",
Desc: "你的身体像一棵需要舒展的大树。肝气就像树的枝叶,需要自由伸展才能舒畅。你积极进取,但也容易因压力而紧绷——胸闷、偏头痛、情绪波动都是身体在说\"我需要透透气\"。春天是你最敏感的季节,别把情绪关在心里。",
Body: "体型偏瘦或适中,面色偏青黄。指甲容易脆裂,眼睛容易干涩——这都是肝血在提醒你。春季容易过敏,舌边偏红。",
Tips: "疏肝理气是头等大事。多运动、多表达、别把委屈憋在心里。玫瑰花茶是你的好朋友,明亮通风的环境就是你的充电站。",
Diseases: "偏头痛、高血压、甲状腺结节、乳腺增生、月经不调、胁间神经痛。长期压抑情绪会\"化火伤阴\"。",
SeasonRisk: "春天最敏感——过敏、血压波动。秋天金克木,容易情绪低落。",
FoodTherapy: "绿色蔬菜是主角(菠菜、芹菜、西兰花)。少来点酸的(山楂、柠檬),别过量。玫瑰花茶、薄荷茶疏肝解郁。荞麦、小米养肝血。",
DailyRhythm: "早上醒来拉伸5分钟,像猫一样舒展。上午11点前搞定重要决策(肝气最旺)。晚上11点前入睡养肝血。",
},
"火": {
Primary: "湿热质",
Secondary: "阴虚质",
Strong: "心",
Weak: "肺",
Desc: "你的内心有一团温暖的火。热情、感染力强是你的超能力,但这团火也容易烧得太旺——心浮气躁、失眠多梦、口腔溃疡都是信号。夏天是你最需要注意的季节,学会给内心降温。",
Body: "面色偏红,手心脚心热乎乎的,容易出汗。皮肤容易出油长痘,舌尖偏红。夏天尤其难熬,总想喝冰的。",
Tips: "清心降火、静养心神。午休(11-13点)是黄金养心时间。莲子心茶、菊花茶是你的清凉剂。少熬夜,少参与激烈争吵——伤的是自己。",
Diseases: "失眠、口腔溃疡反复、心悸焦虑、高血压、皮肤痘痘。严重时心火下移,可能引起尿路问题。",
SeasonRisk: "夏天最敏感——暑热+心火,容易心烦中暑。冬天水克火,注意防寒护心。",
FoodTherapy: "苦味清心(苦瓜、莲子心)。红色食物养心(红枣、枸杞、番茄)。绿豆汤、百合莲子羹清心安神。西瓜、黄瓜解暑生津。",
DailyRhythm: "午时(11-13点)小憩15-30分钟,像给手机充电。傍晚微微出汗的运动最好。睡前温水泡脚,把火气引下去。",
},
"土": {
Primary: "痰湿质",
Secondary: "气虚质",
Strong: "脾",
Weak: "肾",
Desc: "你像大地一样稳重可靠,是所有人都想依靠的人。但思虑太多会伤脾——饭后犯困、身体沉重、大便不成形,都是脾胃在喊累。长夏湿热季节是你最需要照顾自己的时候。脾胃好,一切都好。",
Body: "体型偏丰满,肌肉松软,腹部容易囤积。面色偏黄,舌头胖大。梅雨季会特别不舒服,头重脚轻。",
Tips: "健脾祛湿是核心功课。吃饭七分饱,细嚼慢咽——这不是规矩,是爱自己。山药薏米粥、陈皮茶都是好帮手。别老坐着,动起来帮脾胃运转。",
Diseases: "肥胖、高血脂、脂肪肝、慢性胃炎、消化不良、眩晕、湿疹。",
SeasonRisk: "长夏(7-8月)最难熬——湿热交蒸,浑身沉重。春天肝气犯脾,容易胃痛腹泻。",
FoodTherapy: "甘淡的食物最养脾(山药、茯苓、薏米)。黄色食物是脾胃的好朋友(小米、南瓜、玉米)。陈皮理气,生姜暖中。冰的、甜的、腻的——少碰。",
DailyRhythm: "早餐7-9点必须吃好吃饱(胃经当令)。饭后散步10分钟助消化。晚餐早一点、少一点。",
},
"金": {
Primary: "阴虚质",
Secondary: "气郁质",
Strong: "肺",
Weak: "肝",
Desc: "你像一把精密的乐器,条理分明、追求完美。但肺为\"娇脏\",怕燥怕干——皮肤干燥、秋冬咳嗽、喉咙不适都是它在提醒你。秋天是你最要呵护自己的季节,给自己多一点滋润。",
Body: "偏瘦,皮肤容易干燥起皮,嘴唇干裂。手心脚心热,晚上容易盗汗。秋天特别敏感,容易便秘。",
Tips: "滋阴润肺是关键。白色食物是你的好朋友(银耳、百合、雪梨)。辛辣烧烤油炸——能躲就躲。早睡比什么都养阴,腹式呼吸能帮你增强肺力。",
Diseases: "慢性咽炎、干咳、支气管炎、皮肤干燥、便秘、过敏性鼻炎、荨麻疹。",
SeasonRisk: "秋天最敏感——燥邪伤肺,干咳咽痒。夏天火克金,心肺都要护。",
FoodTherapy: "白色食物润肺(银耳、百合、山药、雪梨、莲藕)。蜂蜜、麦冬、沙参滋阴润燥。菊花、薄荷清肺。远离辛辣、烧烤、烟酒。",
DailyRhythm: "清晨(5-7点大肠经)排清宿便。上午深呼吸5分钟。午后吃个梨或喝银耳羹。晚上9点前入睡养肺阴。保持房间湿度50%-60%。",
},
"水": {
Primary: "阳虚质",
Secondary: "痰湿质",
Strong: "肾",
Weak: "心",
Desc: "你像深潭里的水,沉静而有深度。智慧是你的底色,但阳气不足会让你畏寒怕冷、腰膝酸软、缺乏安全感。肾是先天之本,冬天是你最需要温暖的季节——对自己好一点,从保暖开始。",
Body: "体型偏丰满或有浮肿感,面色偏白或晦暗。手脚冰凉,晚上起夜多,头发容易掉。腰膝冷痛是常见信号。",
Tips: "温阳补肾是首要任务。多晒太阳,特别是晒后背——那是你的\"阳气充电板\"。艾灸关元、命门、腰要暖、脚要热。黑豆、核桃、肉桂都是你的好朋友。",
Diseases: "慢性肾炎、甲减、骨质疏松、腰椎问题、耳鸣耳聋、不孕不育。寒湿导致关节痛。",
SeasonRisk: "冬天最难熬——寒气伤阳,浑身发冷。长夏湿气困肾,水肿加重。",
FoodTherapy: "黑色食物补肾(黑豆、黑芝麻、黑米、桑葚)。核桃、山药、枸杞温补肾阳。海带、紫菜适量。冰镇饮料、生冷瓜果——戒了。",
DailyRhythm: "早晨7-9点晒太阳15分钟补阳气。上午搓腰眼至发热。下午5-7点(肾经当令)休息养肾。晚上热水泡脚至微微出汗。冬天早睡晚起。",
},
}
type EmotionProfile struct {
Strength []string
Risk []string
Advice string
Color string
}
var emotionByElement = map[string]EmotionProfile{
"木": {
Strength: []string{"创造力", "决策力", "学习能力", "进取心", "目标感"},
Risk: []string{"焦虑", "易怒", "急躁", "偏头痛倾向", "经前紧张(女)"},
Advice: "遇事深呼吸三秒再回应。每日快走30分钟疏解肝气。学习正念冥想。",
Color: "绿",
},
"火": {
Strength: []string{"热情", "感染力", "表达力", "社交能力", "行动力"},
Risk: []string{"失眠", "焦躁", "心神不宁", "多梦", "舌尖溃疡"},
Advice: "午时(11-13点)小憩养心。少刷手机,睡前温水泡脚。练习书法静心。",
Color: "红",
},
"土": {
Strength: []string{"稳重", "包容", "耐心", "执行力", "可信赖"},
Risk: []string{"思虑过度", "脾胃不适", "优柔寡断", "过度操心"},
Advice: "饭后散步10分钟助运化。学会说\"不\",减轻负担。培养一个动手爱好。",
Color: "黄",
},
"金": {
Strength: []string{"条理", "决断", "专注", "毅力", "原则性"},
Risk: []string{"悲伤", "忧愁", "呼吸道敏感", "皮肤干燥", "便秘"},
Advice: "练习腹式呼吸强肺。多与朋友交流倾诉。保持环境湿度。",
Color: "白",
},
"水": {
Strength: []string{"智慧", "洞察", "冷静", "适应力", "深度思考"},
Risk: []string{"恐惧", "孤独感", "腰膝酸软", "听力下降", "记忆力减退"},
Advice: "冬季注意腰腹保暖。练习站桩固肾。多听音乐舒缓恐惧。",
Color: "黑",
},
}
+179 -112
View File
@@ -1,5 +1,4 @@
// Package portrait builds deterministic personal-portrait content from birth date.
// Copy follows .ai/product/lexicon.md — exploration / analysis, not fortune-telling.
// Package portrait builds 愈心解码 content from birth date (classic decode engine).
package portrait
import (
@@ -13,134 +12,202 @@ type Output struct {
Detail map[string]any `json:"detail"`
}
// Build generates 个人画像 content from a birth date (deterministic).
// Build generates 愈心解码 content (deterministic for a given birth date).
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)]
tri := Calc(y, int(m), d)
main := tri.O
person := numberPeople[main]
name := displayName
if name == "" {
name = "你"
}
oneLiner := firstSentence(person.Desc)
if oneLiner == "" {
oneLiner = person.Title
}
bz := getBazi(y, int(m), d, 12)
pct := calc5E(bz)
strong := strongestEl(pct)
weak := weakestEl(pct)
cst := constitutionByElement[strong]
emo := emotionByElement[strong]
dims := []map[string]any{
{"key": "personality", "title": "性格特点", "teaser": oneLiner, "score": dimScore(main, 0)},
{"key": "communication", "title": "沟通方式", "teaser": teaserCut(oneLiner, 28), "score": dimScore(main, 1)},
{"key": "relation", "title": "关系模式", "teaser": "深度关系模式见完整分析", "score": dimScore(main, 2)},
{"key": "career", "title": "事业节奏", "teaser": "适合方向见深度版", "score": dimScore(main, 3)},
{"key": "emotion", "title": "情绪调节", "teaser": joinCN(emo.Strength, 3), "score": dimScore(main, 4)},
{"key": "lifestyle", "title": "生活节奏", "teaser": "核心养护:养" + cst.Strong + " · 需呵护" + cst.Weak, "score": dimScore(main, 5)},
}
keywords := []string{fmt.Sprintf("%d号人", main), person.Elem + "行倾向", cst.Primary}
if len(emo.Strength) > 0 {
keywords = append(keywords, emo.Strength[0])
}
tip := todayTip(time.Now())
missing, over := missAndOver(tri)
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,
"title": "愈心解码·基础",
"main_number": main,
"person_title": person.Title,
"style_label": person.Title,
"style_badge": fmt.Sprintf("%d号人", main),
"headline": fmt.Sprintf("%s是%d号人 · %s", name, main, person.Title),
"keywords": keywords,
"one_liner": oneLiner,
"overview": oneLiner,
"life_tip": emo.Advice,
"today_practice": emo.Advice,
// 模糊锁占位(非完整深文案,完整内容仅在 detail)
"lock_teaser_number": "天赋优势与人生课题、适合方向与能量长文,解锁后查看。联合码按生命阶段分组解读。",
"lock_teaser_wuxing": "体质特征、调养要点、易感倾向、食疗与作息节律,解锁后查看完整方案。",
"pattern_key": main,
"pyramid": tri.PyramidMap(),
"wuxing": map[string]any{
"bars": wuxingBars(pct),
"primary": cst.Primary,
"strong": cst.Strong,
"weak": cst.Weak,
"strong_el": strong,
"weak_el": weak,
"emotion": emo.Strength,
"care_dir": "养" + cst.Strong + " · 需呵护" + cst.Weak,
},
"today_tip": tip,
"dimensions": dims,
"strengths_preview": []string{
fmt.Sprintf("%d号人·%s", main, person.Title),
cst.Primary,
},
"blind_spots_preview": []string{"完整天赋优势、人生课题、联合码与体质深析见深度版。"},
"disclaimer_constitution": "体质与调养内容为生活方式参考,非医疗建议。",
}
numberDeep := map[string]any{
"talent": person.Pos,
"topic": person.Neg,
"direction": person.Job,
"elem": person.Elem,
"desc": person.Desc,
"missing": missing,
"over": over,
"joints": jointGroups(tri),
}
constitutionDeep := map[string]any{
"desc": cst.Desc,
"body": cst.Body,
"tips": cst.Tips,
"risk": cst.Diseases,
"season_risk": cst.SeasonRisk,
"food": cst.FoodTherapy,
"rhythm": cst.DailyRhythm,
"primary": cst.Primary,
"secondary": cst.Secondary,
"emotion_risk": emo.Risk,
"emotion_advice": emo.Advice,
}
detail := map[string]any{
"title": "完整分析",
"behavior_pattern": trait.Behavior,
"relation_style": trait.Relation,
"growth_direction": trait.Growth,
"daily_suggestions": []string{
trait.LifeTip,
"遇到分歧时,先复述对方观点再表达自己的需要。",
"用一周记录情绪与精力高峰,找到更适合自己的节奏。",
"title": "完整分析",
"number_deep": numberDeep,
"constitution_deep": constitutionDeep,
"sections": []map[string]any{
section("天赋与课题", person.Desc, []string{"天赋优势:" + person.Pos, "人生课题:" + person.Neg, "适合方向:" + person.Job}),
section("体质特征", cst.Body, []string{cst.Tips}),
section("易感与季节", cst.Diseases, []string{cst.SeasonRisk}),
section("食疗方向", cst.FoodTherapy, nil),
section("作息节律", cst.DailyRhythm, nil),
section("情绪调养", emo.Advice, emo.Risk),
},
"behavior_pattern": person.Desc,
"relation_style": person.Neg,
"growth_direction": person.Pos,
"strengths": splitComma(person.Pos),
"blind_spots": splitComma(person.Neg),
"growth_plan": []map[string]any{
{"phase": "本周", "focus": emo.Advice},
{"phase": "本月", "focus": cst.Tips},
{"phase": "长期", "focus": "结合数字课题「" + person.Neg + "」与体质养护「养" + cst.Strong + "」,形成可持续的生活节奏。"},
},
"conversation_scripts": []string{
"我想聊聊自己的节奏,不用急着给建议。",
"当我压力大时,需要一点安静空间再继续聊。",
"帮我一起看看,哪件事最值得这周聚焦。",
},
"daily_suggestions": []string{strAny(tip["yi"]), emo.Advice, "核心养护:养" + cst.Strong},
"faq": []map[string]string{
{"q": "数字性格是固定的吗?", "a": "主数提供稳定的自我探索框架,具体表现会随阶段与环境变化,可当作镜子而非标签。"},
{"q": "五行体质能当看病依据吗?", "a": "不能。这里的体质与调养只是生活方式参考,不适请就医,勿自行当作治疗方案。"},
},
}
return Output{Summary: summary, Detail: detail}
}
type trait struct {
Label string
Keywords []string
OneLiner string
LifeTip string
Behavior string
Relation string
Growth string
func section(title, body string, bullets []string) map[string]any {
return map[string]any{"title": title, "body": body, "bullets": bullets}
}
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 dimScore(main, salt int) int {
// Stable pseudo-scores 6292 for relation dimension compare.
return 62 + (main*7+salt*11)%31
}
func reduce(n int) int {
if n < 0 {
n = -n
func teaserCut(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
sum := 0
for n > 0 {
sum += n % 10
n /= 10
}
return sum
return string(r[:n]) + "…"
}
func joinCN(parts []string, n int) string {
if len(parts) == 0 {
return ""
}
if len(parts) < n {
n = len(parts)
}
out := parts[0]
for i := 1; i < n; i++ {
out += " · " + parts[i]
}
return out
}
func splitComma(s string) []string {
if s == "" {
return nil
}
// pos/neg use顿号
parts := []string{}
cur := ""
for _, r := range s {
if r == '、' || r == '' || r == ',' {
if cur != "" {
parts = append(parts, cur)
cur = ""
}
continue
}
cur += string(r)
}
if cur != "" {
parts = append(parts, cur)
}
if len(parts) > 3 {
return parts[:3]
}
return parts
}
func strAny(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
+72 -7
View File
@@ -1,24 +1,89 @@
package portrait
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestBuildDeterministic(t *testing.T) {
birth := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC)
func TestCalcMatchesClassicJS(t *testing.T) {
// Snapshot from js/common.js calc(1990,5,12)
v := Calc(1990, 5, 12)
if v.O != 9 || v.M != 8 || v.N != 1 || v.I != 3 || v.J != 5 || v.K != 1 || v.L != 9 {
t.Fatalf("triangle mismatch: %+v", v)
}
if v.Q != 1 || v.P != 8 || v.R != 9 || v.T != 2 || v.S != 4 || v.U != 6 {
t.Fatalf("outer mismatch: %+v", v)
}
}
func TestBuildDecodeShape(t *testing.T) {
birth := time.Date(1990, 5, 12, 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")
if a.Summary["main_number"].(int) != 9 {
t.Fatalf("main_number want 9 got %#v", a.Summary["main_number"])
}
for _, kw := range []string{"运势", "吉凶", "算命"} {
s := a.Summary["one_liner"].(string) + a.Detail["behavior_pattern"].(string)
if strings.Contains(s, kw) {
if a.Summary["person_title"] == nil || a.Summary["pyramid"] == nil {
t.Fatalf("missing decode free fields")
}
wx, ok := a.Summary["wuxing"].(map[string]any)
if !ok || wx["bars"] == nil || wx["primary"] == nil {
t.Fatalf("missing wuxing summary: %#v", a.Summary["wuxing"])
}
if a.Summary["today_tip"] == nil {
t.Fatalf("missing today_tip")
}
nd, ok := a.Detail["number_deep"].(map[string]any)
if !ok || nd["talent"] == nil || nd["joints"] == nil {
t.Fatalf("missing number_deep")
}
cd, ok := a.Detail["constitution_deep"].(map[string]any)
if !ok || cd["body"] == nil || cd["food"] == nil {
t.Fatalf("missing constitution_deep")
}
dims, ok := a.Summary["dimensions"].([]map[string]any)
if !ok || len(dims) < 6 {
t.Fatalf("expected 6 dimensions for relation compat")
}
assertNoForbidden(t, a)
}
func TestWuxingDeterministic(t *testing.T) {
bz := getBazi(1990, 5, 12, 12)
p := calc5E(bz)
// Snapshot aligned with js calc5E(getBazi(new Date(1990,4,12),12))
want := map[string]int{"木": 25, "火": 50, "土": 13, "金": 13, "水": 0}
for k, w := range want {
if p[k] != w {
t.Fatalf("wuxing[%s]=%d want %d (full %#v)", k, p[k], w, p)
}
}
if strongestEl(p) != "火" {
t.Fatalf("strong want 火 got %s", strongestEl(p))
}
}
func TestAllMainNumbersLexicon(t *testing.T) {
for day := 1; day <= 28; day++ {
out := Build(time.Date(1990, 3, day, 0, 0, 0, 0, time.UTC), "测")
assertNoForbidden(t, out)
}
}
func assertNoForbidden(t *testing.T, out Output) {
t.Helper()
raw, err := json.Marshal(out)
if err != nil {
t.Fatal(err)
}
blob := string(raw)
for _, kw := range []string{"占卜", "算命", "改命", "塔罗"} {
if strings.Contains(blob, kw) {
t.Fatalf("forbidden word %q in portrait copy", kw)
}
}
+107
View File
@@ -0,0 +1,107 @@
package portrait
// Joint code short blurbs (JOINT from js/common.js).
var jointCodes = map[string]string{
"112": "独当一面,做事靠谱有担当",
"123": "天生讲师,开口就能圈粉",
"134": "灵感落地,把创意变成现实",
"145": "运筹帷幄,天生的规划师",
"156": "八方来财,处处遇贵人",
"167": "白手起家,靠自己闯天下",
"178": "成就他人,身边贵人不断",
"189": "停不下来的工作狂",
"191": "独立开创,没人能阻挡你",
"213": "口才炸裂,感染力爆棚",
"224": "心太软,善良是你的底牌",
"235": "沟通达人,三言两语暖人心",
"246": "贵人环绕,销冠体质",
"257": "直觉导航,跟着感觉走就对了",
"268": "重情重义,一诺千金",
"279": "异性缘超好,走到哪都是焦点",
"281": "大单收割机,谈判桌王者",
"292": "幕后军师,运筹帷幄之中",
"314": "创意执行力,说干就干",
"325": "感性沟通,用温度打动人心",
"336": "资源连接器,人脉即财富",
"347": "深耕专业,做领域里的王",
"358": "行动派领袖,带队冲锋",
"369": "万花筒人生,十八般武艺样样通",
"415": "规划力MAX,步步为营",
"426": "智慧型销售,成交于无形",
"437": "学术大佬,坐冷板凳也能发光",
"448": "靠谱天花板,交给你就放心了",
"459": "策划大师,脑子比电脑快",
"461": "稳扎稳打,厚积薄发",
"472": "火眼金睛,精准分析一切",
"483": "技术大牛,专业领域独孤求败",
"494": "完美规划师,细节控本控",
"516": "方向感+大智慧,走哪都是对的",
"527": "感性直觉,第六感超准",
"538": "天生领袖,气场两米八",
"549": "规划型赢家,稳操胜券",
"551": "风一样自由,方向感自带导航",
"562": "会赚也会花,享受生活",
"573": "贵人相助命,总有高人来指点",
"584": "责任+规划,双核驱动",
"595": "梦想家体质,心有多大舞台就有多大",
"617": "白手起家,命运由我不由天",
"628": "财富+沟通,双商在线",
"639": "多才多艺,斜杠青年本青",
"641": "务实派经营者,脚踏实地",
"652": "财富雷达,赚钱直觉超准",
"663": "完美主义+财富,精品人生",
"674": "天生傲骨,不甘平凡",
"685": "商业+责任,事业标杆",
"696": "极致追求,匠人精神",
"718": "钻研成王,责任铸就传奇",
"729": "桃花运旺,魅力无法挡",
"731": "创新领袖,敢为天下先",
"742": "神机妙算,谋定而后动",
"753": "直觉行动派,干就完了",
"764": "精打细算,每一分钱都花在刀刃上",
"775": "王者基因,注定站在C位",
"786": "商业领袖,运筹帷幄",
"797": "极致深钻,领域天花板",
"819": "亲力亲为,掌控全局",
"821": "大单高手,说服力满格",
"832": "行动+沟通,效率拉满",
"843": "技术专家,一技傍身走天下",
"854": "规划+自由,收放自如",
"865": "财富+责任,扛得住世界",
"876": "商业王者,霸气外露",
"887": "强势担当,说到做到",
"898": "大事业格局,志在千里",
"911": "大爱无疆,独立而温柔",
"922": "温柔至善,人间小天使",
"933": "巧手匠心,万物皆可造",
"944": "稳中求进,计划周全",
"955": "自由博爱,灵魂有趣",
"966": "完美+智慧,人间清醒",
"977": "深度大爱,为理想燃烧",
"988": "商业帝国缔造者",
"999": "极致巅峰,满分人生",
}
var missMsg = map[int]string{
1: "内心的领袖力还在沉睡——试着在小事上果断决策,你会发现自己比想象中更强大",
2: "你的声音值得被听见——从今天开始,勇敢说出你的想法,别怕被拒绝",
3: "行动力是等待点燃的火种——别再想了,先迈出第一步,剩下的路会自动展开",
4: "安全感不是等来的,是建出来的——给自己一个可以依靠的小习惯,它会慢慢长大",
5: "你的指南针正在校准中——去尝试那些让你心跳加速的事,方向自会出现",
6: "财富嗅觉是可以培养的——从关注自己的价值开始,你会发现自己值得更多",
7: "深度的力量需要聚焦——找一个让你好奇的问题,像侦探一样追下去",
8: "你的使命感正在觉醒——找到那件让你热血沸腾的事,扛起来走下去",
9: "大爱需要边界——学会对自己说\"够了\",你的能量会用在对的地方",
}
var missStrong = map[int]string{
1: "自信女神本神,偶尔听一下别人的意见会更完美",
2: "沟通力溢出屏幕,但别忘了先连接自己的内心",
3: "活力四射到飞起,偶尔刹车踩一踩,走得更远",
4: "稳得像座山,但偶尔翻个墙看看外面的世界也不错",
5: "自由到炸裂,找个值得停靠的港湾也很酷",
6: "完美主义拉满,对自己说一句\"已经够好了\"试试",
7: "沉迷思考无法自拔,偶尔出来晒晒太阳吧",
8: "责任扛得太多啦,分一些给信得过的人",
9: "爱心泛滥到溢出,聚焦一件最想改变的事会更有力量",
}
+86
View File
@@ -0,0 +1,86 @@
package portrait
// NumberPerson is classic NUM[1..9] copy from js/common.js.
type NumberPerson struct {
Title string
Elem string
Pos string
Neg string
Job string
Desc string
}
var numberPeople = map[int]NumberPerson{
1: {
Title: "天生领袖·开拓者",
Elem: "金",
Pos: "自信果敢、创造力爆棚、天生自带光芒",
Neg: "偶尔孤傲、不擅示弱、容易一个人扛下所有",
Job: "创始人、管理者、艺术家、独立设计师",
Desc: "你是天生的领航员,内心有一团不灭的火。你不需要别人告诉你该做什么——你比谁都清楚。独立、果敢、行动力超强,这让你在人群中自带光环。但请记住,真正的强大不是独自承担一切,而是学会信任和依靠。偶尔放下铠甲,你会看到更广阔的世界。",
},
2: {
Title: "温柔沟通·共情者",
Elem: "水",
Pos: "善解人意、沟通力满分、团队里的粘合剂",
Neg: "太在意他人眼光、容易委屈自己、选择困难",
Job: "心理咨询师、公关专家、教育者、客户关系",
Desc: "你有一颗比常人更柔软的心。你能听见别人没说出口的话,感受到空气里的情绪——这是你独一无二的天赋。你是团队里最温暖的存在,总能让人感到被理解。但要记得,你的感受同样重要。温柔不是软弱,拒绝不是伤害。学会为自己发声,你的温柔会更有力量。",
},
3: {
Title: "创意火花·行动派",
Elem: "木",
Pos: "灵感不断、感染力满分、想到就去做",
Neg: "三分钟热度、急躁冒进、耐不住寂寞",
Job: "演员、讲师、创意总监、自媒体人",
Desc: "你的大脑永远在高速运转,灵感像烟花一样炸开。你就是那个能把想法变成现实的人——当别人还在犹豫,你已经冲出去了。你的热情能点燃整个房间。但有时候,慢一点反而更快。不是每个火花都需要燃烧,学会聚焦,你的光芒会更耀眼。",
},
4: {
Title: "稳妥规划·筑造者",
Elem: "木",
Pos: "逻辑缜密、靠谱担当、凡事井井有条",
Neg: "过于保守、害怕改变、安全感容易动摇",
Job: "工程师、会计师、项目经理、架构师",
Desc: "你是那个让所有人放心的人。当世界一片混乱时,你已经在默默列清单了。你的稳不是无趣,是把混乱变成秩序的超能力。每一步都走得扎实,每个细节都心中有数。但生活不是待办清单,偶尔打破计划,会有惊喜。允许自己不那么完美,你已经足够好了。",
},
5: {
Title: "自由灵魂·探索者",
Elem: "土",
Pos: "无拘无束、方向感超强、幽默感爆棚",
Neg: "讨厌束缚、容易逃避、很难安定下来",
Job: "旅行博主、作家、自由职业者、冒险家",
Desc: "你就是风,没人能把你关在笼子里。你的灵魂渴望远方,对世界永远保持好奇。你能在迷茫中找到方向,这是你最珍贵的天赋。你的人生不是按部就班,而是充满冒险的故事。但也要记得,自由不是逃避,真正的自由是拥有选择的能力。找到值得你停留的热爱,那里就是你的家。",
},
6: {
Title: "智慧远见·鉴赏家",
Elem: "金",
Pos: "眼光毒辣、财富直觉、精益求精",
Neg: "完美主义、容易焦虑、对自己太狠",
Job: "投资人、咨询顾问、医生、律师",
Desc: "你有一双看透本质的眼睛。别人看到表面,你看到价值;别人追逐潮流,你预判趋势。你对品质的要求不是挑剔,是对生活的尊重。但完美不是终点,生活有瑕疵才真实。偶尔放低标准,你会发现世界比你想象的更宽容。你值得拥有最好的,包括对自己温柔一点。",
},
7: {
Title: "深度洞察·求真者",
Elem: "水",
Pos: "钻研到底、直觉惊人、看透事物本质",
Neg: "疏离冷漠、疑心重、社交电量容易耗尽",
Job: "科学家、侦探、程序员、学者",
Desc: "别人看热闹,你看门道。你的大脑是永动的探索引擎,对世界的真相充满饥渴。你不需要浅薄的社交,你在意的是深度和意义。这份专注让你在专业领域无人能敌。但请偶尔走出自己的世界,那些看似平凡的人间烟火,也能给你答案。独处是力量,连接是温暖,两者你都值得拥有。",
},
8: {
Title: "责任担当·成就者",
Elem: "土",
Pos: "责任感爆棚、商业嗅觉、野心勃勃",
Neg: "压力山大、控制欲强、容易透支自己",
Job: "企业家、高管、投资人、政治家",
Desc: "你注定不是普通人。你肩膀上扛着比常人更重的使命,心中有比常人更大的蓝图。你的责任感和行动力让你天然成为领袖。但你不需要扛起整个世界,真正的领导者懂得分担负重。学会休息不是懈怠,是为了走更远的路。你值得被照顾,不只是照顾别人。",
},
9: {
Title: "博爱大器·成功者",
Elem: "火",
Pos: "慈悲大爱、机会磁铁、自带成功气场",
Neg: "贪多嚼不烂、难以专注、理想主义过头",
Job: "慈善家、艺人、灵性导师、社会活动家",
Desc: "你身上有一种让人想靠近的魔力。你的心中装着比个人更大的东西——爱与使命感是你最强的驱动力。机会总是向你涌来,因为你值得。你天生自带好运体质。但不需要抓住所有机会,挑一个最让你心动的,全力以赴。你的存在,本身就是一种治愈。",
},
}
+237
View File
@@ -0,0 +1,237 @@
package portrait
// Triangle is the classic life-number pyramid from js/common.js calc().
type Triangle struct {
AB, CD, EF, GH [2]int
I, J, K, L int
M, N, O int
Q, P, R int
T, S, U int
V, W, X int
}
// Calc builds the digital psychology triangle for a birth date.
func Calc(y, m, d int) Triangle {
dd := digs(d, 2)
mm := digs(m, 2)
yy := digs(y, 4)
a, b := dd[0], dd[1]
c, dd1 := mm[0], mm[1]
e, f, g, h := yy[0], yy[1], yy[2], yy[3]
v := Triangle{
AB: [2]int{a, b},
CD: [2]int{c, dd1},
EF: [2]int{e, f},
GH: [2]int{g, h},
}
v.I = s1(a + b)
v.J = s1(c + dd1)
v.K = s1(e + f)
v.L = s1(g + h)
v.M = s1(v.I + v.J)
v.N = s1(v.K + v.L)
v.O = s1(v.M + v.N)
v.Q = s1(v.N + v.O)
v.P = s1(v.M + v.O)
v.R = s1(v.Q + v.P)
v.T = s1(v.I + v.M)
v.S = s1(v.J + v.M)
v.U = s1(v.T + v.S)
v.V = s1(v.K + v.N)
v.W = s1(v.L + v.N)
v.X = s1(v.V + v.W)
return v
}
// PyramidMap returns grid values for H5 mini-triangle rendering.
func (v Triangle) PyramidMap() map[string]int {
return map[string]int{
"I": v.I, "J": v.J, "K": v.K, "L": v.L,
"M": v.M, "N": v.N, "O": v.O,
"Q": v.Q, "P": v.P, "R": v.R,
"T": v.T, "S": v.S, "U": v.U,
"V": v.V, "W": v.W, "X": v.X,
}
}
func s1(n int) int {
for n >= 10 {
sum := 0
for n > 0 {
sum += n % 10
n /= 10
}
n = sum
}
return n
}
func digs(n, l int) []int {
s := fmtPad(n, l)
out := make([]int, l)
for i := 0; i < l; i++ {
out[i] = int(s[i] - '0')
}
return out
}
func fmtPad(n, l int) string {
if n < 0 {
n = -n
}
buf := make([]byte, l)
for i := l - 1; i >= 0; i-- {
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf)
}
func cell(v Triangle, key string) int {
switch key {
case "I":
return v.I
case "J":
return v.J
case "K":
return v.K
case "L":
return v.L
case "M":
return v.M
case "N":
return v.N
case "O":
return v.O
case "Q":
return v.Q
case "P":
return v.P
case "R":
return v.R
case "T":
return v.T
case "S":
return v.S
case "U":
return v.U
case "V":
return v.V
case "W":
return v.W
case "X":
return v.X
default:
return 0
}
}
// jointGroups builds age-grouped joint codes (付费层).
func jointGroups(v Triangle) []map[string]any {
pairs := [][2]string{
{"I", "J"}, {"J", "M"}, {"M", "I"}, {"K", "L"},
{"L", "N"}, {"N", "K"}, {"M", "O"}, {"O", "N"},
{"M", "N"}, {"Q", "P"}, {"Q", "O"}, {"P", "O"},
}
joints := make([]map[string]any, 0, len(pairs))
for _, p := range pairs {
code := fmtInt(cell(v, p[0])) + fmtInt(cell(v, p[1]))
label := "—"
for k, desc := range jointCodes {
if len(k) >= len(code) && k[:len(code)] == code {
label = desc
break
}
}
joints = append(joints, map[string]any{"code": code, "label": label})
}
g1Desc := "由潜意识码主导,反映你在职场上升期和社交圈中的表现模式。"
if v.I+v.J+v.M >= 10 {
g1Desc += "能量较强,事业心旺盛,社交活跃。"
} else {
g1Desc += "能量内敛,更注重深度而非广度。"
}
g2Desc := "由内心码和主性格主导,体现你对下一代和下级的培养方式及中年价值观。"
if v.K+v.L+v.N >= 10 {
g2Desc += "有较强的责任感和支配欲。"
} else {
g2Desc += "倾向于给予空间,放手让他人成长。"
}
g3Desc := "由外心码主导,展现你晚年的生活智慧和家庭关系的最终呈现。"
if v.M+v.N+v.O >= 10 {
g3Desc += "晚年活跃,持续发挥影响力。"
} else {
g3Desc += "晚年宁静,享受内在平和。"
}
return []map[string]any{
{"label": "21—40岁 · 工作和朋友", "codes": joints[0:4], "desc": g1Desc},
{"label": "41—60岁 · 儿女和下属", "codes": joints[4:8], "desc": g2Desc},
{"label": "61岁至晚年 · 晚年和家庭", "codes": joints[8:12], "desc": g3Desc},
}
}
func missAndOver(v Triangle) (missing []map[string]any, over []map[string]any) {
innerSet := map[int]bool{v.O: true, v.M: true, v.N: true, v.I: true, v.J: true, v.K: true, v.L: true}
outerVals := []int{v.Q, v.P, v.R, v.T, v.S, v.U, v.V, v.W, v.X}
present := map[int]bool{}
for n := range innerSet {
present[n] = true
}
for _, n := range outerVals {
present[n] = true
}
for _, n := range []int{v.AB[0], v.AB[1], v.CD[0], v.CD[1], v.EF[0], v.EF[1], v.GH[0], v.GH[1]} {
present[n] = true
}
freq := map[int]int{}
for i := 1; i <= 9; i++ {
freq[i] = 0
}
allVals := []int{
v.O, v.M, v.N, v.I, v.J, v.K, v.L, v.Q, v.P, v.R, v.T, v.S, v.U, v.V, v.W, v.X,
v.AB[0], v.AB[1], v.CD[0], v.CD[1], v.EF[0], v.EF[1], v.GH[0], v.GH[1],
}
for _, x := range allVals {
if x >= 1 && x <= 9 {
freq[x]++
}
}
missBoth := []int{}
for n := 0; n <= 9; n++ {
if !present[n] {
missBoth = append(missBoth, n)
}
}
missSet := map[int]bool{}
for _, n := range missBoth {
missSet[n] = true
if n >= 1 && n <= 9 {
missing = append(missing, map[string]any{"n": n, "msg": missMsg[n]})
}
}
for i := 1; i <= 9; i++ {
if freq[i] >= 4 && !missSet[i] {
over = append(over, map[string]any{"n": i, "msg": missStrong[i], "count": freq[i]})
}
}
return missing, over
}
func fmtInt(n int) string {
if n < 0 {
n = -n
}
if n < 10 {
return string(rune('0' + n))
}
return fmtPad(n, 2)
}
func firstSentence(s string) string {
for i, r := range s {
if r == '。' || r == '.' {
return s[:i]
}
}
return s
}
+138
View File
@@ -0,0 +1,138 @@
package portrait
import (
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/companion"
)
var (
tg = []string{"甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸"}
dz = []string{"子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"}
tge = map[string]string{"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土", "己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"}
dze = map[string]string{"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土", "巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金", "戌": "土", "亥": "水"}
eo = []string{"木", "火", "土", "金", "水"}
ec = map[string]string{"木": "#5CB85C", "火": "#E54D42", "土": "#E8985A", "金": "#C8923A", "水": "#4A90E2"}
)
var yiJi = map[string]struct{ Yi, Ji string }{
"木": {"早睡养肝血 · 舒展拉伸 · 绿色蔬菜", "久坐 · 生闷气 · 熬夜"},
"火": {"午休15分钟 · 菊花/莲子 · 平心静气", "暴晒 · 辛辣油腻 · 过度兴奋"},
"土": {"规律三餐 · 健脾山药 · 饭后散步", "思虑过度 · 生冷 · 暴饮暴食"},
"金": {"滋润养肺 · 白色食物 · 深呼吸", "干燥受凉 · 悲忧 · 剧烈耗气"},
"水": {"早卧晚起 · 温补黑色食物 · 护腰腹", "受寒 · 过劳 · 惊恐紧张"},
}
// seasonEl maps month index (0=Jan) → 时令五行 (decode.js SEASON_EL).
var seasonEl = []string{"水", "水", "木", "木", "木", "火", "火", "火", "土", "金", "金", "水"}
type pillar struct{ tg, dz string }
type bazi struct {
year, month, day, hour pillar
}
// getBazi mirrors js/common.js getBazi (hour default 12 = 午时).
// Day-pillar index uses civil days since 1900-01-01 minus 1, matching legacy
// browser Date local-midnight flooring under historical CN TZ/DST (decode.js).
func getBazi(y, m, d, h int) bazi {
gzY := pillar{tg: tg[(y-4+10000)%10], dz: dz[(y-4+12000)%12]}
birth := time.Date(y, time.Month(m), d, 0, 0, 0, 0, time.UTC)
dayOfYear := birth.YearDay()
lcDay := 35
mi := int(float64((dayOfYear-lcDay+365)%365) / 30.44)
if mi < 0 {
mi += 12
}
if mi >= 12 {
mi = 11
}
mTg := tg[(((y-4+10000)%10)*2+mi)%10]
mDz := dz[(mi+2)%12]
base := time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC)
dd := int(birth.Sub(base).Hours()/24) - 1
dTg := tg[((dd%10)+10)%10]
dDz := dz[((dd%12)+12)%12]
hDz := dz[((h+1)/2)%12]
hTg := tg[(indexOf(tg, dTg)*2+indexOf(dz, hDz))%10]
return bazi{
year: gzY,
month: pillar{tg: mTg, dz: mDz},
day: pillar{tg: dTg, dz: dDz},
hour: pillar{tg: hTg, dz: hDz},
}
}
func calc5E(bz bazi) map[string]int {
c := map[string]int{"木": 0, "火": 0, "土": 0, "金": 0, "水": 0}
for _, p := range []pillar{bz.year, bz.month, bz.day, bz.hour} {
c[tge[p.tg]]++
c[dze[p.dz]]++
}
t := 0
for _, e := range eo {
t += c[e]
}
r := map[string]int{}
for _, e := range eo {
if t == 0 {
r[e] = 0
} else {
r[e] = int(float64(c[e])/float64(t)*100 + 0.5) // Math.round
}
}
return r
}
func strongestEl(p map[string]int) string {
best := eo[0]
for _, e := range eo[1:] {
if p[e] > p[best] {
best = e
}
}
return best
}
func weakestEl(p map[string]int) string {
worst := eo[0]
for _, e := range eo[1:] {
if p[e] < p[worst] {
worst = e
}
}
return worst
}
func wuxingBars(p map[string]int) []map[string]any {
bars := make([]map[string]any, 0, 5)
for _, e := range eo {
bars = append(bars, map[string]any{
"el": e, "pct": p[e], "color": ec[e],
})
}
return bars
}
func todayTip(now time.Time) map[string]any {
st := companion.TodaySolar(now)
el := seasonEl[int(now.Month())-1]
yj := yiJi[el]
return map[string]any{
"solar_term": st.Name,
"season_el": el,
"yi": yj.Yi,
"ji": yj.Ji,
"tip": st.Tip,
}
}
func indexOf(arr []string, s string) int {
for i, v := range arr {
if v == s {
return i
}
}
return 0
}
+405 -25
View File
@@ -1,11 +1,15 @@
// Package relation builds 关系理解 content from two birth-based portraits.
// Package relation builds 人格匹配 / 合盘向 content from two profiles.
package relation
import (
"fmt"
"math"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
)
// Output is free summary + gated detail.
@@ -14,8 +18,18 @@ type Output struct {
Detail map[string]any
}
// Build compares two profiles' exploration styles (lexicon-safe copy).
// Build compares two profiles' exploration styles.
func Build(aBirth, bBirth time.Time, aName, bName string) Output {
return BuildWithType(aBirth, bBirth, aName, bName, "")
}
// BuildWithType adds relation-type flavored tips (partner/family/friend).
func BuildWithType(aBirth, bBirth time.Time, aName, bName, relationType string) Output {
return BuildFull(aBirth, bBirth, nil, nil, nil, nil, aName, bName, relationType)
}
// BuildFull includes birth time/place for natal synastry indices.
func BuildFull(aBirth, bBirth time.Time, aTime, bTime, aPlace, bPlace *string, aName, bName, relationType string) Output {
if aName == "" {
aName = "我"
}
@@ -24,41 +38,393 @@ func Build(aBirth, bBirth time.Time, aName, bName string) Output {
}
pa := portrait.Build(aBirth, aName)
pb := portrait.Build(bBirth, bName)
aLabel := str(pa.Summary["headline"])
bLabel := str(pb.Summary["headline"])
aLabel := firstNonEmpty(str(pa.Summary["style_label"]), str(pa.Summary["headline"]))
bLabel := firstNonEmpty(str(pb.Summary["style_label"]), str(pb.Summary["headline"]))
aKeys := strSlice(pa.Summary["keywords"])
bKeys := strSlice(pb.Summary["keywords"])
aDims := dimMap(pa.Summary["dimensions"])
bDims := dimMap(pb.Summary["dimensions"])
compKey := complementarityKey(aLabel, bLabel)
comp := complementarityCopy(compKey, aName, bName, aLabel, bLabel)
dimCompare := []map[string]any{}
for _, key := range []string{"personality", "communication", "relation", "career", "emotion", "lifestyle"} {
title := dimTitle(key)
as, aok := aDims[key]
bs, bok := bDims[key]
if !aok || !bok {
continue
}
gap := as.Score - bs.Score
abs := int(math.Abs(float64(gap)))
note := dimNote(key, gap, aName, bName)
dimCompare = append(dimCompare, map[string]any{
"key": key, "title": title,
"me_score": as.Score, "other_score": bs.Score,
"me_teaser": as.Teaser, "other_teaser": bs.Teaser,
"gap": abs, "note": note,
})
}
aSun, aMoon, aRise := star.SignLabelsPlace(aBirth, aTime, aPlace)
bSun, bMoon, bRise := star.SignLabelsPlace(bBirth, bTime, bPlace)
ca, errA := star.NatalChart(aBirth, aTime, aPlace)
cb, errB := star.NatalChart(bBirth, bTime, bPlace)
if errA != nil || errB != nil {
ca, cb = natal.Chart{}, natal.Chart{}
}
match := synastry.Compute(ca, cb, aName, bName)
harmony := harmonyIndex(dimCompare)
fitLabel, fitTips := fitFromHarmony(harmony, aLabel, bLabel)
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": "了解彼此的沟通方式,查看关系理解",
"title": "人格匹配·基础",
"me_style": aLabel,
"other_style": bLabel,
"me_keywords": aKeys,
"other_keywords": bKeys,
"diff_one_liner": comp.OneLiner,
"overview": comp.Overview,
"chemistry": comp.Chemistry,
"watchouts_preview": comp.Watchouts[:min(2, len(comp.Watchouts))],
"dimension_compare": dimCompare,
"fit_label": fitLabel,
"fit_tips": fitTips,
"harmony_index": harmony,
"love_index": match.Love,
"friend_index": match.Friend,
"marriage_index": match.Marriage,
"match_indices": match.AsMap(),
"star_compare": []map[string]any{
{"key": "sun", "title": "太阳", "me": aSun, "other": bSun, "note": starPairNote(aSun, bSun)},
{"key": "moon", "title": "月亮", "me": aMoon, "other": bMoon, "note": starPairNote(aMoon, bMoon)},
{"key": "rise", "title": "上升", "me": aRise, "other": bRise, "note": starPairNote(aRise, bRise)},
},
"share_hint": "了解彼此的沟通方式,查看人格匹配",
}
detail := map[string]any{
"title": "相处建议·完整分析",
"communication": []string{
fmt.Sprintf("%s:先讲清楚事实与需要,再谈感受。", aName),
fmt.Sprintf("%s:先确认被听见,再进入方案讨论。", bName),
"冲突时约定「复述对方一句」再回应,降低误解。",
"title": "人格匹配·完整分析",
"sections": []map[string]any{
{"title": "匹配总览", "body": comp.DeepOverview, "bullets": comp.ChemistryPoints},
{"title": "星座合盘与匹配指数", "body": fmt.Sprintf("%s。%s太阳%s / %s太阳%s;月亮%s与%s。", match.Summary, aName, aSun, bName, bSun, aMoon, bMoon),
"bullets": []string{match.LoveNote, match.FriendNote, match.MarriageNote, starPairNote(aSun, bSun)}},
{"title": "沟通协作", "body": comp.CommDeep, "bullets": comp.CommTips},
{"title": "冲突修复", "body": comp.ConflictDeep, "bullets": comp.ConflictTips},
{"title": "亲密与边界", "body": comp.IntimacyDeep, "bullets": comp.IntimacyTips},
{"title": "共同成长", "body": comp.GrowthDeep, "bullets": comp.GrowthTips},
},
"interaction": []string{
"用「我观察到…我需要…」代替指责句式。",
"每周留一次轻松同步:最近什么在消耗/滋养这段关系。",
},
"maintenance": []string{
"差异标签不是评分,而是协作说明书。",
"重要决定前,双方各写三点顾虑再合并。",
},
"me_behavior": str(pa.Detail["behavior_pattern"]),
// legacy flat lists (tests / older clients)
"communication": append([]string{
fmt.Sprintf("%s%s):%s", aName, aLabel, firstTeaser(aDims, "communication")),
fmt.Sprintf("%s%s):%s", bName, bLabel, firstTeaser(bDims, "communication")),
}, comp.CommTips...),
"interaction": comp.IntimacyTips,
"maintenance": comp.GrowthTips,
"me_behavior": str(pa.Detail["behavior_pattern"]),
"other_behavior": str(pb.Detail["behavior_pattern"]),
"strengths_together": comp.ChemistryPoints,
"friction_points": comp.Watchouts,
"weekly_practice": comp.Weekly,
"conversation_scripts": comp.Scripts,
"faq": []map[string]string{
{"q": "差异大是不是不合适?", "a": "差异本身不是问题,关键是双方是否愿意把差异当成说明书,而不是评分表。"},
{"q": "总吵怎么办?", "a": "先停火复述,再谈方案;把「谁对谁错」换成「我们各自需要什么」。"},
},
}
relLabel, relBody, relBullets := relationTypePack(relationType, aName, bName)
if relLabel != "" {
summary["relation_type"] = relationType
summary["relation_type_label"] = relLabel
secs, _ := detail["sections"].([]map[string]any)
detail["sections"] = append([]map[string]any{
{"title": "关系类型:" + relLabel, "body": relBody, "bullets": relBullets},
}, secs...)
}
return Output{Summary: summary, Detail: detail}
}
func relationTypePack(t, aName, bName string) (label, body string, bullets []string) {
switch t {
case "partner", "恋人", "伴侣":
return "伴侣",
fmt.Sprintf("%s与%s更适合把差异写成「亲密说明书」:欲望、节奏与安全感都说清楚。", aName, bName),
[]string{"每周一次情绪复盘,不谈对错", "亲密请求用「我需要」句式", "边界:疲惫时先暂停再继续"}
case "family", "家人", "父母", "亲子":
return "家人",
fmt.Sprintf("家人关系里,%s与%s容易把旧角色带进新对话。试着把对方当「现在的人」而不是旧剧本。", aName, bName),
[]string{"少用「你总是」句式", "大事拆成可协商的小请求", "保留各自的私人空间"}
case "friend", "朋友":
return "朋友",
fmt.Sprintf("友情里%s与%s可以更轻松地互补:约会期待与回应频率说开即可。", aName, bName),
[]string{"约见用明确时间,减少猜测", "忙时用短消息保持连结", "冲突后用玩笑或直接道歉都行,别冷处理太久"}
default:
return "", "", nil
}
}
type dimScore struct {
Score int
Teaser string
}
func dimMap(v any) map[string]dimScore {
out := map[string]dimScore{}
arr, ok := v.([]map[string]any)
if !ok {
// Build() uses []map[string]any — also tolerate []any
raw, ok2 := v.([]any)
if !ok2 {
return out
}
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
key := str(m["key"])
out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])}
}
return out
}
for _, m := range arr {
key := str(m["key"])
out[key] = dimScore{Score: asInt(m["score"]), Teaser: str(m["teaser"])}
}
return out
}
func asInt(v any) int {
switch n := v.(type) {
case int:
return n
case float64:
return int(n)
default:
return 0
}
}
func dimTitle(key string) string {
switch key {
case "personality":
return "性格特点"
case "communication":
return "沟通方式"
case "relation":
return "关系模式"
case "career":
return "事业节奏"
case "emotion":
return "情绪调节"
case "lifestyle":
return "生活节奏"
default:
return key
}
}
func dimNote(key string, gap int, aName, bName string) string {
if gap > 12 {
return fmt.Sprintf("在「%s」上,%s 分值更高,适合由 %s 多给结构,%s 多给弹性。", dimTitle(key), aName, aName, bName)
}
if gap < -12 {
return fmt.Sprintf("在「%s」上,%s 分值更高,相处时可多尊重 %s 的节奏。", dimTitle(key), bName, bName)
}
return fmt.Sprintf("在「%s」上双方接近,容易形成默契,也要防止都默认对方「应该懂」。", dimTitle(key))
}
func firstTeaser(m map[string]dimScore, key string) string {
if d, ok := m[key]; ok && d.Teaser != "" {
return d.Teaser
}
return "表达方式各有节奏"
}
func complementarityKey(a, b string) string {
if a == b {
return "same"
}
// simple buckets by trait family
drive := map[string]string{
"稳进探索者": "steady", "细腻分析者": "steady", "守护担当者": "steady", "洞察策略者": "steady",
"敏锐连接者": "warm", "温和协调者": "warm", "热忱鼓舞者": "warm",
"果断行动派": "drive", "自由创造者": "drive",
}
ak, bk := drive[a], drive[b]
if ak == "" || bk == "" {
return "mix"
}
if ak == bk {
return "same_family"
}
if (ak == "steady" && bk == "warm") || (ak == "warm" && bk == "steady") {
return "steady_warm"
}
if (ak == "drive" && bk == "steady") || (ak == "steady" && bk == "drive") {
return "drive_steady"
}
if (ak == "drive" && bk == "warm") || (ak == "warm" && bk == "drive") {
return "drive_warm"
}
return "mix"
}
type compCopy struct {
OneLiner, Overview, Chemistry, DeepOverview, CommDeep, ConflictDeep, IntimacyDeep, GrowthDeep string
ChemistryPoints, Watchouts, CommTips, ConflictTips, IntimacyTips, GrowthTips, Weekly, Scripts []string
}
func complementarityCopy(key, aName, bName, aLabel, bLabel string) compCopy {
base := compCopy{
OneLiner: fmt.Sprintf("%s偏「%s」,%s偏「%s」——差异可以写成相处说明书。", aName, aLabel, bName, bLabel),
Overview: fmt.Sprintf("双方在表达、节奏与需求上并不相同。把差异看清楚,比急着证明「谁更对」更有用。下面从沟通、冲突、亲密与共同成长几个维度展开。"),
Chemistry: "互补往往出现在:一方给结构,另一方给温度;或一方推进,另一方稳住质量。",
Weekly: []string{
"本周进行一次 20 分钟「非解决问题」闲聊或散步。",
"各自写三件「我需要你这样支持我」的具体行为,互换阅读。",
"约定一个冲突停火词,任一方说出即暂停 15 分钟。",
},
Scripts: []string{
fmt.Sprintf("%s可以说:我需要先把事实说清楚,再谈感受。", aName),
fmt.Sprintf("%s可以说:我希望你先听到我的感受,再给方案。", bName),
"我们可以先复述对方一句,再表达自己的需要。",
},
}
switch key {
case "same":
base.OneLiner = fmt.Sprintf("你们风格接近(都偏「%s」),默契来得快,也要防止一起陷入同样的盲区。", aLabel)
base.Chemistry = "同类相吸:理解成本低,推进或回避也可能同步发生。"
base.DeepOverview = "风格相近意味着你们很容易「懂对方在想什么」,但也可能同时逃避冲突,或同时过度冲刺。建议定期引入外部视角(朋友建议、清单复盘),打破镜像盲区。"
base.CommDeep = "沟通效率高,但要刻意练习提出不同意见。安排「唱反调」轮值:每周一人专门提出风险点。"
base.ConflictDeep = "冲突可能被快速和好掩盖,问题未真正处理。用「问题清单」追踪未完成议题。"
base.IntimacyDeep = "熟悉感强,新鲜感需主动创造:共同学习或小旅行比重复日常更能充电。"
base.GrowthDeep = "一起设定一个共同小目标,并互相做问责伙伴。"
base.ChemistryPoints = []string{"理解成本低", "节奏容易对齐", "共同语言多"}
base.Watchouts = []string{"共享同一盲区", "缺少外部校正", "意见过于一致缺少张力"}
base.CommTips = []string{"鼓励提出异议", "重要决定写利弊表", "避免默认对方已懂"}
base.ConflictTips = []string{"追踪未完成议题", "避免假性和好", "冷静后再做决定"}
base.IntimacyTips = []string{"主动制造新鲜体验", "表达感谢要具体", "保留个人空间"}
base.GrowthTips = []string{"共同目标 + 问责", "每月复盘一次关系", "引入可信第三方建议"}
case "steady_warm":
base.Chemistry = "稳与暖互补:一方提供结构与可靠,另一方提供连接与温度。"
base.DeepOverview = fmt.Sprintf("%s与%s之间,最常见的张力是「要先讲清楚」还是「要先被看见」。若能轮流满足这两种需求,关系会既安全又有温度。", aName, bName)
base.CommDeep = "沟通协议:情绪话题先共鸣 2 分钟,再进入事实与方案;事务话题先结论,再补感受。"
base.ConflictDeep = "稳的一方别用沉默当结束;暖的一方别用追问升级压力。停火后用「我需要…」重开。"
base.IntimacyDeep = "暖的一方需要回应频率;稳的一方需要可预期的独处。把两者写进约定。"
base.GrowthDeep = "把互补写成分工:谁更擅长安抚,谁更擅长推进落地。"
base.ChemistryPoints = []string{"结构 × 温度", "可靠 × 连接", "可形成完整支持系统"}
base.Watchouts = []string{"一方觉得被冷落", "一方觉得被情绪淹没", "节奏错位积累委屈"}
base.CommTips = []string{"情绪先共鸣再方案", "事务先结论再感受", "用文字确认关键约定"}
base.ConflictTips = []string{"禁止用沉默结束话题", "追问前先问是否方便", "停火词机制"}
base.IntimacyTips = []string{"约定回应窗口", "尊重独处不被解读为冷淡", "每周一次深度连接"}
base.GrowthTips = []string{"按优势分工", "互相学习对方语言", "月度关系复盘"}
case "drive_steady":
base.Chemistry = "推与稳互补:一方破局加速,另一方把关质量与可持续。"
base.DeepOverview = "行动派容易嫌分析派慢;稳健派容易嫌行动派莽。把「速度」用在试验,「稳健」用在关键承诺,冲突会下降。"
base.CommDeep = "行动方给时间盒与最小方案;稳健方在时限内给风险清单,而不是无限延期。"
base.ConflictDeep = "冲突焦点常是节奏。先对齐「这是可逆试验还是重大决定」,再选速度。"
base.IntimacyDeep = "行动方用陪伴质量弥补碎片时间;稳健方减少用担忧浇灭热情,改用「我支持你试,我们设检查点」。"
base.GrowthDeep = "共同项目里明确角色:谁启动、谁验收、何时复盘。"
base.ChemistryPoints = []string{"破局 × 把关", "速度 × 质量", "试验与承诺可分工"}
base.Watchouts = []string{"节奏互斥", "一方压抑热情", "一方焦虑失控"}
base.CommTips = []string{"先定义可逆/不可逆", "时间盒决策", "风险清单限时"}
base.ConflictTips = []string{"争论节奏前先分类问题", "避免人格化指责", "用检查点代替否决"}
base.IntimacyTips = []string{"质量陪伴", "支持试验+检查点", "庆祝小进展"}
base.GrowthTips = []string{"项目角色清晰", "复盘节奏", "互相翻译动机"}
case "drive_warm":
base.Chemistry = "驱动与连接互补:一方带节奏,另一方维系人心与氛围。"
base.DeepOverview = "热情与效率碰到一起很有火花,也容易在「推进」与「照顾感受」之间拉扯。约定场景切换:冲刺模式 / 连接模式。"
base.CommDeep = "冲刺时短讯同步进度;连接时关掉任务话题。不要用效率语言处理情绪时刻。"
base.ConflictDeep = "驱动方避免「你想太多」;连接方避免「你只在乎结果」。改说具体需求。"
base.IntimacyDeep = "用共同体验(运动、活动)同时满足推进感与连接感。"
base.GrowthDeep = "轮流做「本周关系主理人」,负责安排一次连接或一次共同目标。"
base.ChemistryPoints = []string{"推进力 × 氛围", "行动号召力强", "共同体验易充电"}
base.Watchouts = []string{"情绪被效率压过", "承诺过多难兑现", "连接变任务化"}
base.CommTips = []string{"模式切换:冲刺/连接", "情绪时刻禁用效率话术", "进度短讯化"}
base.ConflictTips = []string{"禁止否定感受", "需求具体化", "修复后再推进"}
base.IntimacyTips = []string{"共同体验", "兑现小承诺", "非任务陪伴"}
base.GrowthTips = []string{"轮值关系主理人", "控制并行承诺", "庆祝与复盘并重"}
case "same_family":
base.DeepOverview = "你们属于相近气质族,容易互相理解,也要主动制造一点建设性差异,避免舒适区停滞。"
base.CommDeep = "沟通顺畅时更要确认细节,防止「好像说好了」其实理解不同。"
base.ConflictDeep = "冲突可能被淡化。强制做一次「最担心的三件事」互换。"
base.IntimacyDeep = "在舒适之外增加挑战性共同任务,刷新关系动能。"
base.GrowthDeep = "互相指出对方一个盲区,并约定本月各改一项小行为。"
base.ChemistryPoints = []string{"气质相近", "理解门槛低", "协作起步快"}
base.Watchouts = []string{"舒适区停滞", "细节默认错误", "回避尖锐议题"}
base.CommTips = []string{"确认细节", "书面关键约定", "鼓励异议"}
base.ConflictTips = []string{"互换担忧清单", "不假性和好", "设讨论截止"}
base.IntimacyTips = []string{"共同挑战任务", "新鲜体验", "具体感谢"}
base.GrowthTips = []string{"互指一个盲区", "月改一小行为", "外部输入"}
default:
base.DeepOverview = fmt.Sprintf("%s与%s风格路径不同,说明书价值更高。先承认差异合法,再谈协作规则。", aName, bName)
base.CommDeep = "建立双通道:事实通道与感受通道,讨论前先声明走哪一条。"
base.ConflictDeep = "冲突时回到共同目标句:我们都希望关系更好/事情做成。"
base.IntimacyDeep = "用定期同步取代猜测;空间与连接都要有配额。"
base.GrowthDeep = "把差异写成「我擅长 / 我需要」对照表,贴在看得见的地方。"
base.ChemistryPoints = []string{"视角多样", "可互补决策", "扩展彼此舒适区"}
base.Watchouts = []string{"误解成本高", "价值观冲突需早谈", "节奏长期错位"}
base.CommTips = []string{"声明沟通通道", "复述再回应", "关键约定书面化"}
base.ConflictTips = []string{"回到共同目标", "停火机制", "一次只谈一个议题"}
base.IntimacyTips = []string{"定期同步", "空间与连接配额", "具体肯定"}
base.GrowthTips = []string{"擅长/需要对照表", "月度复盘", "小步共同目标"}
}
return base
}
func harmonyIndex(dims []map[string]any) int {
if len(dims) == 0 {
return 72
}
sum := 0
for _, d := range dims {
gap := asInt(d["gap"])
sum += 100 - gap*4
}
avg := sum / len(dims)
if avg < 45 {
return 45
}
if avg > 96 {
return 96
}
return avg
}
func fitFromHarmony(score int, aLabel, bLabel string) (string, []string) {
switch {
case score >= 82:
return "默契互补型", []string{
fmt.Sprintf("%s与%s节奏接近,适合共同推进小事。", aLabel, bLabel),
"把欣赏说出口,默契会更稳。",
"每周留一次轻松同步,不必每次谈大事。",
}
case score >= 68:
return "磨合成长型", []string{
"差异可见,正好写成相处说明书。",
"冲突时先复述再提方案。",
"共同目标写清楚,减少猜忌。",
}
default:
return "反差探索型", []string{
"反差大不等于不合,关键是边界与节奏。",
"重要约定尽量具体、可检查。",
"给彼此独处充电的空间。",
}
}
}
func starPairNote(a, b string) string {
if a == b {
return fmt.Sprintf("同为%s:容易共鸣,也要避免同质盲区。", a)
}
return fmt.Sprintf("%s × %s:节奏不同,适合「我负责启动 / 你负责收尾」式分工。", a, b)
}
func str(v any) string {
s, _ := v.(string)
return s
@@ -81,3 +447,17 @@ func strSlice(v any) []string {
}
return out
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+32 -4
View File
@@ -1,6 +1,7 @@
package relation
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -10,13 +11,40 @@ 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")
if out.Summary["diff_one_liner"] == nil || out.Summary["overview"] == nil {
t.Fatal("missing summary fields")
}
blob := str(out.Summary["diff_one_liner"]) + str(out.Detail["title"])
for _, bad := range []string{"合盘", "运势", "吉凶", "合婚"} {
dims, ok := out.Summary["dimension_compare"].([]map[string]any)
if !ok || len(dims) < 3 {
t.Fatalf("expected dimension_compare, got %#v", out.Summary["dimension_compare"])
}
secs, ok := out.Detail["sections"].([]map[string]any)
if !ok || len(secs) < 4 {
t.Fatalf("expected detail sections")
}
raw, _ := json.Marshal(out)
blob := string(raw)
for _, bad := range []string{"算命"} {
if strings.Contains(blob, bad) {
t.Fatalf("forbidden %q", bad)
}
}
if out.Summary["harmony_index"] == nil || out.Summary["star_compare"] == nil {
t.Fatal("missing match fields")
}
if out.Summary["love_index"] == nil || out.Summary["friend_index"] == nil || out.Summary["marriage_index"] == nil {
t.Fatal("missing match indices")
}
}
func TestBuildSameStyle(t *testing.T) {
d := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC)
out := Build(d, d, "我", "TA")
if !strings.Contains(str(out.Summary["diff_one_liner"]), "接近") &&
!strings.Contains(str(out.Summary["diff_one_liner"]), "说明书") {
// same birth => same style; copy should mention 接近 or still valid
if out.Detail["weekly_practice"] == nil {
t.Fatal("missing weekly_practice")
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package repository
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// AskRepo persists ask threads and messages.
type AskRepo struct {
Pool *pgxpool.Pool
}
// CreateThread inserts a thread bound to a profile.
func (r *AskRepo) CreateThread(ctx context.Context, userID, profileID uuid.UUID, scene *string) (*model.AskThread, error) {
t := &model.AskThread{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO ask_threads(user_id, profile_id, scene)
VALUES ($1,$2,$3)
RETURNING id, user_id, profile_id, scene, created_at`,
userID, profileID, scene,
).Scan(&t.ID, &t.UserID, &t.ProfileID, &t.Scene, &t.CreatedAt)
return t, err
}
// GetThreadForUser loads a thread owned by user.
func (r *AskRepo) GetThreadForUser(ctx context.Context, userID, threadID uuid.UUID) (*model.AskThread, error) {
t := &model.AskThread{}
err := r.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, scene, created_at
FROM ask_threads WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
threadID, userID,
).Scan(&t.ID, &t.UserID, &t.ProfileID, &t.Scene, &t.CreatedAt)
return t, err
}
// ListMessages returns messages in a thread (oldest first).
func (r *AskRepo) ListMessages(ctx context.Context, threadID uuid.UUID) ([]model.AskMessage, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, thread_id, role, content, created_at
FROM ask_messages WHERE thread_id=$1 AND deleted_at IS NULL
ORDER BY created_at ASC`, threadID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.AskMessage
for rows.Next() {
var m model.AskMessage
if err := rows.Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// InsertMessage stores one message.
func (r *AskRepo) InsertMessage(ctx context.Context, threadID uuid.UUID, role, content string) (*model.AskMessage, error) {
m := &model.AskMessage{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO ask_messages(thread_id, role, content)
VALUES ($1,$2,$3)
RETURNING id, thread_id, role, content, created_at`,
threadID, role, content,
).Scan(&m.ID, &m.ThreadID, &m.Role, &m.Content, &m.CreatedAt)
return m, err
}
// CountUserAssistantMessages counts all assistant replies for quota (free tier).
func (r *AskRepo) CountUserAssistantMessages(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM ask_messages m
JOIN ask_threads t ON t.id = m.thread_id
WHERE t.user_id=$1 AND m.role='assistant' AND m.deleted_at IS NULL AND t.deleted_at IS NULL`,
userID,
).Scan(&n)
return n, err
}
// ConsumeMembershipQuota decrements ask_quota_left when membership is active.
// Returns false if no active membership or quota is already 0.
func (r *AskRepo) ConsumeMembershipQuota(ctx context.Context, userID uuid.UUID) (ok bool, left int, err error) {
err = r.Pool.QueryRow(ctx, `
UPDATE memberships
SET ask_quota_left = ask_quota_left - 1, updated_at=now()
WHERE user_id=$1 AND status='active' AND expires_at > now()
AND deleted_at IS NULL AND ask_quota_left > 0
RETURNING ask_quota_left`, userID,
).Scan(&left)
if errors.Is(err, pgx.ErrNoRows) {
return false, 0, nil
}
if err != nil {
return false, 0, err
}
return true, left, nil
}
+131
View File
@@ -0,0 +1,131 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// GrowthRepo persists growth plans and check-ins.
type GrowthRepo struct {
Pool *pgxpool.Pool
}
// CreatePlan inserts a plan.
func (r *GrowthRepo) CreatePlan(ctx context.Context, userID uuid.UUID, title, focus string) (*model.GrowthPlan, error) {
p := &model.GrowthPlan{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO growth_plans(user_id, title, focus)
VALUES ($1,$2,$3)
RETURNING id, user_id, title, focus, status, created_at`,
userID, title, focus,
).Scan(&p.ID, &p.UserID, &p.Title, &p.Focus, &p.Status, &p.CreatedAt)
return p, err
}
// ListPlans returns active plans for user.
func (r *GrowthRepo) ListPlans(ctx context.Context, userID uuid.UUID) ([]model.GrowthPlan, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, title, focus, status, created_at
FROM growth_plans
WHERE user_id=$1 AND deleted_at IS NULL AND status='active'
ORDER BY created_at DESC LIMIT 20`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.GrowthPlan
for rows.Next() {
var p model.GrowthPlan
if err := rows.Scan(&p.ID, &p.UserID, &p.Title, &p.Focus, &p.Status, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// Checkin upserts today's check-in.
func (r *GrowthRepo) Checkin(ctx context.Context, userID, planID uuid.UUID, day time.Time, note *string) (*model.GrowthCheckin, error) {
var owner uuid.UUID
err := r.Pool.QueryRow(ctx, `
SELECT user_id FROM growth_plans WHERE id=$1 AND deleted_at IS NULL`, planID,
).Scan(&owner)
if err != nil {
return nil, errors.New("plan not found")
}
if owner != userID {
return nil, errors.New("plan not found")
}
c := &model.GrowthCheckin{}
var dayOut time.Time
err = r.Pool.QueryRow(ctx, `
INSERT INTO growth_checkins(plan_id, user_id, day, note)
VALUES ($1,$2,$3::date,$4)
ON CONFLICT (plan_id, day) DO UPDATE SET note=EXCLUDED.note
RETURNING id, plan_id, user_id, day, note, created_at`,
planID, userID, day.Format("2006-01-02"), note,
).Scan(&c.ID, &c.PlanID, &c.UserID, &dayOut, &c.Note, &c.CreatedAt)
if err != nil {
return nil, err
}
c.Day = dayOut.Format("2006-01-02")
return c, nil
}
// ListCheckinsRecent returns check-ins for a plan in last N days.
func (r *GrowthRepo) ListCheckinsRecent(ctx context.Context, userID, planID uuid.UUID, days int) ([]model.GrowthCheckin, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, plan_id, user_id, day, note, created_at
FROM growth_checkins
WHERE user_id=$1 AND plan_id=$2 AND day >= (CURRENT_DATE - $3::int)
ORDER BY day DESC`, userID, planID, days)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.GrowthCheckin
for rows.Next() {
var c model.GrowthCheckin
var dayOut time.Time
if err := rows.Scan(&c.ID, &c.PlanID, &c.UserID, &dayOut, &c.Note, &c.CreatedAt); err != nil {
return nil, err
}
c.Day = dayOut.Format("2006-01-02")
out = append(out, c)
}
return out, rows.Err()
}
// ListMoodsRecent for companion trail (reuse moods table).
func (r *GrowthRepo) ListMoodsRecent(ctx context.Context, userID uuid.UUID, days int) ([]model.Mood, error) {
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, day, score, note, created_at
FROM moods
WHERE user_id=$1 AND deleted_at IS NULL AND day >= (CURRENT_DATE - $2::int)
ORDER BY day DESC`, userID, days)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Mood
for rows.Next() {
var m model.Mood
var dayOut time.Time
if err := rows.Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt); err != nil {
return nil, err
}
m.Day = dayOut.Format("2006-01-02")
out = append(out, m)
}
return out, rows.Err()
}
// ErrNoRows re-export helper.
var ErrNoRows = pgx.ErrNoRows
@@ -0,0 +1,77 @@
package repository
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const imageCardDailyFree = 3
// ImageCardRepo tracks daily draw quotas.
type ImageCardRepo struct {
Pool *pgxpool.Pool
}
// UsedToday returns how many free draws used today.
func (r *ImageCardRepo) UsedToday(ctx context.Context, userID uuid.UUID, day time.Time) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT used FROM image_card_quotas
WHERE user_id=$1 AND day=$2::date`,
userID, day.Format("2006-01-02"),
).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return n, err
}
// TryConsume increments today's used count when under free limit.
// Returns remaining after consume. ErrQuotaExhausted when free tier is out.
func (r *ImageCardRepo) TryConsume(ctx context.Context, userID uuid.UUID, day time.Time) (remaining int, err error) {
dayStr := day.Format("2006-01-02")
var used int
err = r.Pool.QueryRow(ctx, `
INSERT INTO image_card_quotas(user_id, day, used)
VALUES ($1, $2::date, 1)
ON CONFLICT (user_id, day) DO UPDATE
SET used = image_card_quotas.used + 1
WHERE image_card_quotas.used < $3
RETURNING used`,
userID, dayStr, imageCardDailyFree,
).Scan(&used)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrQuotaExhausted
}
if err != nil {
return 0, err
}
return imageCardDailyFree - used, nil
}
// RemainingToday without consuming.
func (r *ImageCardRepo) RemainingToday(ctx context.Context, userID uuid.UUID, day time.Time, unlimited bool) (int, error) {
if unlimited {
return 99, nil
}
used, err := r.UsedToday(ctx, userID, day)
if err != nil {
return 0, err
}
left := imageCardDailyFree - used
if left < 0 {
left = 0
}
return left, nil
}
// DailyFreeLimit is the free-tier cap.
func DailyFreeLimit() int { return imageCardDailyFree }
// ErrQuotaExhausted means free daily draws are used up.
var ErrQuotaExhausted = errors.New("今日免费次数已用完")
+55
View File
@@ -0,0 +1,55 @@
package repository
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// MoodRepo persists daily moods.
type MoodRepo struct {
Pool *pgxpool.Pool
}
// UpsertToday inserts or updates today's mood for user.
func (r *MoodRepo) UpsertToday(ctx context.Context, userID uuid.UUID, day time.Time, score *int, note *string) (*model.Mood, error) {
m := &model.Mood{}
var dayOut time.Time
err := r.Pool.QueryRow(ctx, `
INSERT INTO moods(user_id, day, score, note)
VALUES ($1, $2::date, $3, $4)
ON CONFLICT (user_id, day) DO UPDATE
SET score = EXCLUDED.score,
note = EXCLUDED.note,
updated_at = now(),
deleted_at = NULL
RETURNING id, user_id, day, score, note, created_at`,
userID, day.Format("2006-01-02"), score, note,
).Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt)
if err != nil {
return nil, err
}
m.Day = dayOut.Format("2006-01-02")
return m, nil
}
// GetToday returns today's mood if any.
func (r *MoodRepo) GetToday(ctx context.Context, userID uuid.UUID, day time.Time) (*model.Mood, error) {
m := &model.Mood{}
var dayOut time.Time
err := r.Pool.QueryRow(ctx, `
SELECT id, user_id, day, score, note, created_at
FROM moods
WHERE user_id=$1 AND day=$2::date AND deleted_at IS NULL`,
userID, day.Format("2006-01-02"),
).Scan(&m.ID, &m.UserID, &dayOut, &m.Score, &m.Note, &m.CreatedAt)
if err != nil {
return nil, err
}
m.Day = dayOut.Format("2006-01-02")
return m, nil
}
+136 -18
View File
@@ -2,6 +2,8 @@ package repository
import (
"context"
"errors"
"math"
"time"
"github.com/google/uuid"
@@ -15,26 +17,40 @@ 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) {
const profileCols = `
id, user_id, relation, display_name, birth_date, relation_type,
CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END,
birth_place, geo_lat, geo_lng, COALESCE(geo_visible, false), created_at`
func scanProfile(scan func(dest ...any) error) (*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)
var bt *string
err := scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &bt,
&p.BirthPlace, &p.GeoLat, &p.GeoLng, &p.GeoVisible, &p.CreatedAt)
if err != nil {
return nil, err
}
p.RelationType = relationType
p.BirthTime = bt
return p, nil
}
// Create inserts a profile.
func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string) (*model.Profile, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type, birth_time, birth_place)
VALUES ($1,$2,$3,$4,$5,
CASE WHEN $6::text IS NULL OR $6::text = '' THEN NULL ELSE $6::time END,
NULLIF(TRIM($7::text), ''))
RETURNING `+profileCols,
userID, relation, name, birth, relationType, birthTime, birthPlace,
)
return scanProfile(row.Scan)
}
// 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
SELECT `+profileCols+`
FROM profiles WHERE user_id=$1 AND deleted_at IS NULL
ORDER BY created_at DESC`, userID)
if err != nil {
@@ -43,25 +59,127 @@ func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model
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 {
p, err := scanProfile(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, p)
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
row := r.Pool.QueryRow(ctx, `
SELECT `+profileCols+`
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)
)
return scanProfile(row.Scan)
}
// GetByID loads any non-deleted profile (for invite host lookup).
func (r *ProfileRepo) GetByID(ctx context.Context, profileID uuid.UUID) (*model.Profile, error) {
row := r.Pool.QueryRow(ctx, `
SELECT `+profileCols+`
FROM profiles WHERE id=$1 AND deleted_at IS NULL`, profileID)
return scanProfile(row.Scan)
}
// UpdateForUser patches display name / birth / geo.
func (r *ProfileRepo) UpdateForUser(ctx context.Context, userID, profileID uuid.UUID, name string, birth time.Time, relationType *string, birthTime *string, birthPlace *string, geoLat, geoLng *float64, geoVisible *bool) (*model.Profile, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE profiles
SET display_name=$3, birth_date=$4, relation_type=$5,
birth_time=CASE WHEN $6::text IS NULL OR $6::text = '' THEN birth_time ELSE $6::time END,
birth_place=CASE WHEN $7::text IS NULL THEN birth_place ELSE NULLIF(TRIM($7::text), '') END,
geo_lat=CASE WHEN $8::float8 IS NULL THEN geo_lat ELSE $8 END,
geo_lng=CASE WHEN $9::float8 IS NULL THEN geo_lng ELSE $9 END,
geo_visible=CASE WHEN $10::bool IS NULL THEN geo_visible ELSE $10 END,
updated_at=now()
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL
RETURNING `+profileCols,
profileID, userID, name, birth, relationType, birthTime, birthPlace, geoLat, geoLng, geoVisible,
)
return scanProfile(row.Scan)
}
// NearbyItem is a visible profile with distance.
type NearbyItem struct {
Profile model.Profile `json:"profile"`
Distance float64 `json:"distance_km"`
}
// ListNearby returns other users' self profiles with geo_visible within radius.
func (r *ProfileRepo) ListNearby(ctx context.Context, excludeUserID uuid.UUID, lat, lng, radiusKm float64, limit int) ([]NearbyItem, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
rows, err := r.Pool.Query(ctx, `
SELECT `+profileCols+`
FROM profiles
WHERE deleted_at IS NULL AND geo_visible = true
AND relation = 'self'
AND user_id <> $1
AND geo_lat IS NOT NULL AND geo_lng IS NOT NULL
LIMIT 200`, excludeUserID)
if err != nil {
return nil, err
}
return p, nil
defer rows.Close()
var out []NearbyItem
for rows.Next() {
p, err := scanProfile(rows.Scan)
if err != nil {
return nil, err
}
if p.GeoLat == nil || p.GeoLng == nil {
continue
}
d := haversineKm(lat, lng, *p.GeoLat, *p.GeoLng)
if d <= radiusKm {
out = append(out, NearbyItem{Profile: *p, Distance: math.Round(d*10) / 10})
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// sort by distance
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Distance < out[i].Distance {
out[i], out[j] = out[j], out[i]
}
}
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func haversineKm(lat1, lng1, lat2, lng2 float64) float64 {
const R = 6371.0
toR := func(d float64) float64 { return d * math.Pi / 180 }
dLat := toR(lat2 - lat1)
dLng := toR(lng2 - lng1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toR(lat1))*math.Cos(toR(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
return 2 * R * math.Asin(math.Sqrt(a))
}
// SoftDeleteForUser marks a profile deleted.
func (r *ProfileRepo) SoftDeleteForUser(ctx context.Context, userID, profileID uuid.UUID) error {
tag, err := r.Pool.Exec(ctx, `
UPDATE profiles SET deleted_at=now(), updated_at=now()
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`,
profileID, userID,
)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errors.New("profile not found")
}
return nil
}
+61 -1
View File
@@ -3,8 +3,11 @@ package repository
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
@@ -38,6 +41,32 @@ func (r *ReportRepo) GetForUser(ctx context.Context, userID, reportID uuid.UUID)
return rep, err
}
// ListForUser returns recent reports for a user (summary only usage at service layer).
func (r *ReportRepo) ListForUser(ctx context.Context, userID uuid.UUID, limit int) ([]model.GrowthReport, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, profile_id, type, summary, detail, created_at
FROM growth_reports
WHERE user_id=$1 AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.GrowthReport
for rows.Next() {
var rep model.GrowthReport
if err := rows.Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt); err != nil {
return nil, err
}
out = append(out, rep)
}
return out, rows.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
@@ -60,6 +89,37 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID)
return ok, err
}
// MembershipRow is the current membership snapshot for a user.
type MembershipRow struct {
Plan string
Status string
ExpiresAt *time.Time
AskQuotaLeft int
Active bool
}
// GetMembership returns membership status; missing row → inactive.
func (r *ReportRepo) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipRow, error) {
var plan, status string
var expires *time.Time
var quota int
err := r.Pool.QueryRow(ctx, `
SELECT plan, status, expires_at, ask_quota_left
FROM memberships
WHERE user_id=$1 AND deleted_at IS NULL`, userID,
).Scan(&plan, &status, &expires, &quota)
if errors.Is(err, pgx.ErrNoRows) {
return &MembershipRow{Active: false, Status: "none"}, nil
}
if err != nil {
return nil, err
}
active := status == "active" && expires != nil && expires.After(time.Now())
return &MembershipRow{
Plan: plan, Status: status, ExpiresAt: expires, AskQuotaLeft: quota, Active: active,
}, nil
}
// 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
@@ -121,7 +181,7 @@ func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) err
}
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)
VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 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()`,
@@ -0,0 +1,125 @@
package repository
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
)
// SynastryInviteRepo persists synastry invite tokens.
type SynastryInviteRepo struct {
Pool *pgxpool.Pool
}
// Create inserts an invite (7-day expiry).
func (r *SynastryInviteRepo) Create(ctx context.Context, hostUser, hostProfile uuid.UUID) (*model.SynastryInvite, error) {
token, err := randomToken(16)
if err != nil {
return nil, err
}
exp := time.Now().UTC().Add(7 * 24 * time.Hour)
inv := &model.SynastryInvite{}
err = r.Pool.QueryRow(ctx, `
INSERT INTO synastry_invites(token, host_user_id, host_profile_id, expires_at)
VALUES ($1,$2,$3,$4)
RETURNING id, token, host_user_id, host_profile_id, expires_at,
guest_user_id, guest_profile_id, report_id, created_at`,
token, hostUser, hostProfile, exp,
).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt,
&inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt)
return inv, err
}
// GetByToken loads a non-deleted invite.
func (r *SynastryInviteRepo) GetByToken(ctx context.Context, token string) (*model.SynastryInvite, error) {
inv := &model.SynastryInvite{}
err := r.Pool.QueryRow(ctx, `
SELECT id, token, host_user_id, host_profile_id, expires_at,
guest_user_id, guest_profile_id, report_id, created_at
FROM synastry_invites WHERE token=$1 AND deleted_at IS NULL`, token,
).Scan(&inv.ID, &inv.Token, &inv.HostUserID, &inv.HostProfileID, &inv.ExpiresAt,
&inv.GuestUserID, &inv.GuestProfileID, &inv.ReportID, &inv.CreatedAt)
if err != nil {
return nil, err
}
return inv, nil
}
// AcceptAtomic creates guest profile + synastry report + marks invite in one transaction.
func (r *SynastryInviteRepo) AcceptAtomic(
ctx context.Context,
inviteID, guestUser uuid.UUID,
name string,
birth time.Time,
birthTime, birthPlace *string,
summary, detail json.RawMessage,
) (*model.GrowthReport, error) {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var guestID uuid.UUID
var bt *string
var bp *string
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO profiles(user_id, relation, display_name, birth_date, birth_time, birth_place)
VALUES ($1,'other',$2,$3,
CASE WHEN $4::text IS NULL OR $4::text = '' THEN NULL ELSE $4::time END,
NULLIF(TRIM($5::text), ''))
RETURNING id,
CASE WHEN birth_time IS NULL THEN NULL ELSE to_char(birth_time, 'HH24:MI') END,
birth_place, created_at`,
guestUser, name, birth, birthTime, birthPlace,
).Scan(&guestID, &bt, &bp, &createdAt)
if err != nil {
return nil, err
}
rep := &model.GrowthReport{}
err = tx.QueryRow(ctx, `
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
VALUES ($1,$2,'synastry',$3,$4)
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
guestUser, guestID, summary, detail,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
if err != nil {
return nil, err
}
tag, err := tx.Exec(ctx, `
UPDATE synastry_invites
SET guest_user_id=$2, guest_profile_id=$3, report_id=$4, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL AND report_id IS NULL AND expires_at > now()`,
inviteID, guestUser, guestID, rep.ID,
)
if err != nil {
return nil, err
}
if tag.RowsAffected() == 0 {
return nil, errors.New("invite already used or expired")
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return rep, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+205
View File
@@ -0,0 +1,205 @@
// Package rhythm builds 身心节律 reports (lifestyle exploration, not fortune/medical claims).
package rhythm
import (
"fmt"
"time"
)
// Output free summary + gated detail.
type Output struct {
Summary map[string]any
Detail map[string]any
}
var elements = []string{"木", "火", "土", "金", "水"}
// Build from birth date.
func Build(birth time.Time, displayName string) Output {
name := displayName
if name == "" {
name = "你"
}
primary := elements[(birth.Year()+int(birth.Month())+birth.Day())%5]
secondary := elements[(birth.YearDay())%5]
if secondary == primary {
secondary = elements[(birth.YearDay()+1)%5]
}
tip := tips[primary]
dims := []map[string]any{
{"key": "element", "title": "主导倾向", "teaser": fmt.Sprintf("更偏「%s」隐喻", primary), "score": 80},
{"key": "balance", "title": "平衡关注", "teaser": fmt.Sprintf("可留意「%s」侧滋养", secondary), "score": 68},
{"key": "sleep", "title": "作息", "teaser": tip.Sleep, "score": 72},
{"key": "diet", "title": "饮食节奏", "teaser": tip.Diet, "score": 70},
{"key": "move", "title": "活动", "teaser": tip.Move, "score": 74},
{"key": "mood", "title": "情绪调节", "teaser": tip.Mood, "score": 71},
}
todayTip := tip.Sleep
weekday := int(time.Now().Weekday())
weekHints := []string{tip.Mood, tip.Move, tip.Diet, tip.Sleep, tip.WeekTip, tip.Move, tip.Mood}
summary := map[string]any{
"title": "身心节律·基础",
"style_label": primary + "倾向",
"headline": fmt.Sprintf("%s的身心节律更偏「%s」隐喻", name, primary),
"one_liner": tip.OneLiner,
"overview": fmt.Sprintf("%s%s主导倾向「%s」,辅助关注「%s」。以下建议用于生活节奏探索,不构成医疗意见。", name, tip.Overview, primary, secondary),
"life_tip": tip.WeekTip,
"today_tip": todayTip,
"week_focus": weekHints[weekday%len(weekHints)],
"keywords": []string{primary, secondary, "生活建议", "节律"},
"dimensions": dims,
"primary_element": primary,
"secondary_element": secondary,
"strengths_preview": tip.Strengths[:min(3, len(tip.Strengths))],
"blind_spots_preview": []string{"完整习惯方案见深度版。"},
}
detail := map[string]any{
"title": "身心节律·完整建议",
"sections": []map[string]any{
{"title": "节律总览", "body": tip.DeepOverview, "bullets": tip.OverviewBullets},
{"title": "作息建议", "body": tip.SleepDeep, "bullets": tip.SleepBullets},
{"title": "饮食节奏", "body": tip.DietDeep, "bullets": tip.DietBullets},
{"title": "活动与放松", "body": tip.MoveDeep, "bullets": tip.MoveBullets},
{"title": "情绪与压力", "body": tip.MoodDeep, "bullets": tip.MoodBullets},
{"title": "与节气联动", "body": "可结合「节气生活」调整当季节奏;变化慢一点,观察身体反馈即可。", "bullets": []string{"查看今日节气", "一次只改一个习惯", "不适就医,勿自行当治疗"}},
{"title": "今日生活建议", "body": tip.Sleep + " " + tip.Mood, "bullets": []string{tip.Move, tip.Diet}},
{"title": "本周节奏", "body": tip.WeekTip, "bullets": tip.OverviewBullets},
},
"strengths": tip.Strengths,
"blind_spots": tip.BlindSpots,
"growth_plan": []map[string]any{
{"phase": "本周", "focus": tip.WeekTip},
{"phase": "本月", "focus": tip.MonthTip},
{"phase": "长期", "focus": "形成「充电/耗电」清单,并与节气提醒一起复盘。"},
},
"conversation_scripts": tip.Scripts,
"faq": []map[string]string{
{"q": "这是占卜或看病吗?", "a": "都不是。这是身心节律的自我探索与生活建议,不预测未来,也不构成医疗诊断。"},
},
"behavior_pattern": tip.DeepOverview,
"relation_style": tip.MoodDeep,
"growth_direction": tip.MonthTip,
}
return Output{Summary: summary, Detail: detail}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
type tipPack struct {
OneLiner, Overview, WeekTip, MonthTip, DeepOverview string
Sleep, Diet, Move, Mood string
SleepDeep, DietDeep, MoveDeep, MoodDeep string
OverviewBullets, SleepBullets, DietBullets, MoveBullets, MoodBullets []string
Strengths, BlindSpots, Scripts []string
}
var tips = map[string]tipPack{
"木": {
OneLiner: "你适合有伸展与目标感的节奏,压得太死容易闷。",
Overview: "在节律隐喻里偏「生发」:需要空间与方向。",
WeekTip: "本周安排三次户外或拉伸,每次 15 分钟。",
MonthTip: "给自己一个可完成的小目标,并拆成周检查点。",
DeepOverview: "木倾向的人常需要「向前」的感觉。卡住时,动一动比硬想更有效。注意肩颈与情绪积压。",
Sleep: "尽量固定入睡窗口", Diet: "清淡多样,少暴饮暴食", Move: "伸展、快走、轻度有氧", Mood: "把烦闷说出来或写下来",
SleepDeep: "规律睡眠能显著降低烦躁。睡前一小时减少争执性内容。",
DietDeep: "少油炸重味,增加新鲜蔬菜;饥一顿饱一顿会放大情绪波动。",
MoveDeep: "优先选择有伸展感的活动,避免长期久坐。",
MoodDeep: "愤怒或闷气是常见信号。命名情绪后做一次短活动再对话。",
OverviewBullets: []string{"需要方向感", "忌长期压抑", "动一动有助疏通"},
SleepBullets: []string{"固定入睡", "睡前减刺激"},
DietBullets: []string{"清淡多样", "规律进餐"},
MoveBullets: []string{"伸展快走", "避免久坐"},
MoodBullets: []string{"命名情绪", "先活动再谈"},
Strengths: []string{"行动欲", "目标感", "适应生长"},
BlindSpots: []string{"易急躁", "压怒", "忽视休息"},
Scripts: []string{"我有点闷,想先走走再聊。", "我需要一个小目标来找到节奏。"},
},
"火": {
OneLiner: "你靠热情与连接充电,也要注意过热后的回落。",
Overview: "节律隐喻偏「温煦」:表达与互动重要。",
WeekTip: "保证两晚 23:30 前入睡,并安排一次轻松社交。",
MonthTip: "学会在热情后安排恢复日,而不是连续硬冲。",
DeepOverview: "火倾向的人能量外放。高潮后空虚很常见,回落期做轻松恢复即可。",
Sleep: "避免熬夜透支", Diet: "少辛辣刺激过量", Move: "有节奏的有氧", Mood: "表达后也要倾听",
SleepDeep: "睡眠差会放大冲动。把「今晚早睡」当成任务完成。",
DietDeep: "辛辣咖啡适量;补水与规律三餐稳住节奏。",
MoveDeep: "运动有助释放兴奋,但避免以透支换快乐。",
MoodDeep: "表达是优点。记得给对方回应空间,也给自己降温时间。",
OverviewBullets: []string{"热情连接", "注意回落", "表达重要"},
SleepBullets: []string{"少熬夜", "降温再睡"},
DietBullets: []string{"刺激适量", "规律进餐"},
MoveBullets: []string{"有氧释放", "勿透支"},
MoodBullets: []string{"表达+倾听", "回落期恢复"},
Strengths: []string{"感染力", "行动号召", "乐观"},
BlindSpots: []string{"过热", "承诺过多", "忽视恢复"},
Scripts: []string{"我很兴奋,我们先定本周唯一下一步。", "我需要今晚早点休息。"},
},
"土": {
OneLiner: "你需要稳定与踏实,变化太快会消耗安全感。",
Overview: "节律隐喻偏「承载」:规律比刺激更重要。",
WeekTip: "固定三餐时间,并完成一次环境整理(桌面/房间一角)。",
MonthTip: "建立一个可重复的晨间或睡前小仪式。",
DeepOverview: "土倾向的人靠可预期感充电。突变多时,用清单与仪式稳住自己。",
Sleep: "同一时间上床", Diet: "温热易消化、勿过饥过饱", Move: "散步、力量轻度", Mood: "少同时处理多件事",
SleepDeep: "规律比时长更重要。周末也不要大幅颠倒作息。",
DietDeep: "细嚼慢咽;减少生冷刺激性饮食实验。",
MoveDeep: "稳定、可坚持的运动优于偶尔高强度。",
MoodDeep: "焦虑常来自失控感。把任务拆小,完成一件勾一项。",
OverviewBullets: []string{"需要稳定", "仪式感有用", "忌突变过多"},
SleepBullets: []string{"同时上床", "周末少颠倒"},
DietBullets: []string{"规律温热", "细嚼"},
MoveBullets: []string{"可坚持", "散步力量"},
MoodBullets: []string{"清单拆解", "单线程"},
Strengths: []string{"踏实", "耐心", "可靠"},
BlindSpots: []string{"抗拒变化", "过度担忧", "行动偏慢"},
Scripts: []string{"我想先把节奏排好,再谈变化。", "给我一点准备时间,我会更稳。"},
},
"金": {
OneLiner: "你重视秩序与边界,混乱环境会让你烦躁。",
Overview: "节律隐喻偏「收敛」:清晰与整理很重要。",
WeekTip: "清理数字或实体杂物 20 分钟,并写下三条边界。",
MonthTip: "练习两次温和而清晰的拒绝。",
DeepOverview: "金倾向的人靠清晰边界获得掌控感。过度苛求完美会消耗自己。",
Sleep: "睡前收工仪式", Diet: "少熬夜夜宵", Move: "呼吸、拉伸、节奏运动", Mood: "把标准调到足够好",
SleepDeep: "工作与睡眠边界要划清;床上不处理未完成清单。",
DietDeep: "规律饮食;减少以咖啡硬撑。",
MoveDeep: "注重呼吸与肩颈;短时高质量运动即可。",
MoodDeep: "挑剔有时是焦虑。用「足够好」完成一件事对抗空转。",
OverviewBullets: []string{"边界清晰", "忌混乱", "完美主义留意"},
SleepBullets: []string{"收工仪式", "床上不工作"},
DietBullets: []string{"少硬撑", "规律"},
MoveBullets: []string{"呼吸拉伸", "短时有效"},
MoodBullets: []string{"足够好", "温和拒绝"},
Strengths: []string{"条理", "原则", "质量感"},
BlindSpots: []string{"过苛", "难放手", "紧绷"},
Scripts: []string{"这次我想按我的节奏来。", "这件事我接不住,我们换个方案。"},
},
"水": {
OneLiner: "你感受深、需要沉淀,被催促时容易关闭。",
Overview: "节律隐喻偏「滋润」:休整与感受空间重要。",
WeekTip: "安排两段不被打扰的静默时间(各 20 分钟)。",
MonthTip: "建立睡前放松流程,减少临睡刷信息。",
DeepOverview: "水倾向的人需要内在空间。催促与过载会让你退缩;适度分享感受可减少误解。",
Sleep: "保证连续睡眠", Diet: "温补平和、少冰冷刺激", Move: "游泳、散步、柔软活动", Mood: "先感受再决策",
SleepDeep: "睡眠是情绪水库。缺觉时少做重大决定。",
DietDeep: "规律温热;观察哪些食物让你更稳。",
MoveDeep: "柔和持续的活动比爆发冲刺更适合你。",
MoodDeep: "情绪来时先命名;写三句或找安全的人听你说完。",
OverviewBullets: []string{"需要沉淀", "忌强催", "感受力强"},
SleepBullets: []string{"连续睡眠", "少缺觉决策"},
DietBullets: []string{"温热平和", "规律"},
MoveBullets: []string{"柔和持续", "散步游泳"},
MoodBullets: []string{"先命名", "再决策"},
Strengths: []string{"洞察", "共情", "韧性"},
BlindSpots: []string{"回避", "反刍", "边界模糊"},
Scripts: []string{"我需要一点时间感受一下,再回复你。", "我不是拖延,我在整理自己。"},
},
}
+21
View File
@@ -0,0 +1,21 @@
package rhythm
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestBuild(t *testing.T) {
out := Build(time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC), "我")
if out.Summary["primary_element"] == nil {
t.Fatal("missing element")
}
raw, _ := json.Marshal(out)
for _, bad := range []string{"运势", "吉凶", "算命", "疗效", "治病"} {
if strings.Contains(string(raw), bad) {
t.Fatalf("forbidden %q", bad)
}
}
}
+188
View File
@@ -0,0 +1,188 @@
package scale
// BuildResult returns a rich, lexicon-safe exploration result for a scale slug + style key.
func BuildResult(slug, styleKey, label string) map[string]interface{} {
pack := resultPack(slug, styleKey, label)
return map[string]interface{}{
"title": "探索结果",
"style_key": styleKey,
"label": pack.Label,
"summary": pack.Summary,
"overview": pack.Overview,
"share_line": pack.ShareLine,
"dimensions": pack.Dimensions,
"strengths": pack.Strengths,
"watchouts": pack.Watchouts,
"tips": pack.Tips,
"scripts": pack.Scripts,
"growth_plan": pack.GrowthPlan,
"faq": pack.FAQ,
}
}
type pack struct {
Label, Summary, Overview, ShareLine string
Dimensions []map[string]interface{}
Strengths, Watchouts, Tips, Scripts []string
GrowthPlan []map[string]string
FAQ []map[string]string
}
func resultPack(slug, key, fallbackLabel string) pack {
switch slug {
case "emotion-pattern":
return emotionPack(key, fallbackLabel)
default:
return communicationPack(key, fallbackLabel)
}
}
func communicationPack(key, fallback string) pack {
switch key {
case "A":
return pack{
Label: "理性澄清型", ShareLine: "我的沟通方式:理性澄清型",
Summary: "你倾向先澄清事实与目标,再进入感受层。这让协作高效,也要注意别让对方觉得被审问。",
Overview: "冲突或合作时,你本能想问清楚「发生了什么、目标是什么」。对方若正处在情绪里,过早追问细节可能升级张力。你的成长点是:先给一句承认,再启动澄清。",
Dimensions: []map[string]interface{}{
{"title": "澄清力", "score": 88, "note": "擅长把模糊说清楚"},
{"title": "共情节奏", "score": 62, "note": "可先共鸣再分析"},
{"title": "边界表达", "score": 78, "note": "需求通常比较明确"},
},
Strengths: []string{"逻辑清楚", "减少误解", "适合协作对齐"},
Watchouts: []string{"过早纠正", "忽略情绪信号", "听起来像质询"},
Tips: []string{"开口先复述对方一句", "结论前加「我听到你…」", "重要约定写成要点"},
Scripts: []string{"我先确认我理解对了:你是说……?", "我想先对齐事实,再一起看感受可以吗?", "我的需要是……,你呢?"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "三次对话里先共鸣再澄清"},
{"phase": "本月", "focus": "冲突后写复盘:事实 / 感受 / 约定"},
},
FAQ: []map[string]string{
{"q": "别人说我太冷静?", "a": "冷静是优势;补一句情绪确认,观感会柔和很多。"},
},
}
case "B":
return pack{
Label: "感受连接型", ShareLine: "我的沟通方式:感受连接型",
Summary: "你擅长用共鸣打开对话。记得在连接之后,也把需求和边界说清楚,避免长期亏空。",
Overview: "你对语气与气氛敏感,能很快让人感到被理解。挑战是容易承接对方情绪,或迟迟不谈具体安排。连接是起点,请求是终点。",
Dimensions: []map[string]interface{}{
{"title": "共情力", "score": 90, "note": "氛围与感受捕捉强"},
{"title": "需求表达", "score": 60, "note": "可练习更直接"},
{"title": "冲突耐受力", "score": 58, "note": "宜早谈小分歧"},
},
Strengths: []string{"让人安心", "破冰能力强", "关系温度高"},
Watchouts: []string{"边界模糊", "回避具体冲突", "情绪连带消耗"},
Tips: []string{"共鸣后补一句清晰请求", "每周提出一次真实偏好", "重要事不要只靠暗示"},
Scripts: []string{"我很理解你的难处,我能做的是……;我需要你……", "我刚才有点被带着走了,让我整理一下再回复。", "这件事我有点在意,我们可以商量吗?"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "一次温和且具体的请求"},
{"phase": "本月", "focus": "建立「情绪收支」复盘"},
},
FAQ: []map[string]string{
{"q": "怎样不那么累?", "a": "把理解与解决分开,不默认承包对方情绪。"},
},
}
default: // C or unknown
label := fallback
if label == "" {
label = "节奏尊重型"
}
return pack{
Label: label, ShareLine: "我的沟通方式:" + label,
Summary: "你重视对方的节奏与空间。这很加分,也要避免把「尊重」变成「不表达」,导致关系空转。",
Overview: "你不愿压迫别人,也反感被催促。优点是让人有安全感;风险是关键议题被无限推迟。可以为重要话题设定温和的时间盒。",
Dimensions: []map[string]interface{}{
{"title": "节奏感", "score": 86, "note": "懂得留白与等待"},
{"title": "推进力", "score": 64, "note": "重要事可设时间盒"},
{"title": "安全感营造", "score": 82, "note": "压迫感低"},
},
Strengths: []string{"尊重边界", "低压迫沟通", "适合长期协作"},
Watchouts: []string{"议题拖延", "需求不说出口", "被误解为漠不关心"},
Tips: []string{"重要话题约定讨论时间", "用「到点我们再谈」代替无限等待", "主动同步「我在意,只是需要准备」"},
Scripts: []string{"这件事对我重要,我们明天晚上 20 点谈 20 分钟可以吗?", "我不是不在乎,我需要一点时间想清楚。", "我想听完你的节奏,再说我的需要。"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "为一个拖延议题约定讨论时间"},
{"phase": "本月", "focus": "练习两次限时决策"},
},
FAQ: []map[string]string{
{"q": "会不会显得被动?", "a": "尊重节奏 + 明确时间点,就是主动而不压迫。"},
},
}
}
}
func emotionPack(key, fallback string) pack {
switch key {
case "A":
return pack{
Label: "觉察表达型", ShareLine: "我的情感模式:觉察表达型",
Summary: "你能较快察觉情绪并愿意说出来。注意表达时给对方消化空间,避免倾泻成压力。",
Overview: "觉察是调节的第一步。你已经具备命名与分享的能力;下一步是区分「分享」与「要求对方立刻修好」。",
Dimensions: []map[string]interface{}{
{"title": "觉察", "score": 88, "note": "情绪信号接收快"},
{"title": "表达", "score": 84, "note": "愿意说出口"},
{"title": "调节后行动", "score": 66, "note": "说完后可补一个小行动"},
},
Strengths: []string{"情绪可见", "利于早沟通", "减少闷烧"},
Watchouts: []string{"倾泻过量", "期待即时修复", "对方跟不上节奏"},
Tips: []string{"表达前先写三句提纲", "说明你要倾听还是建议", "给对方回应窗口"},
Scripts: []string{"我现在有点……,我需要你听我说 5 分钟,先不要给方案。", "我说完了,你方便的时候告诉我你的感受。"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "两次「只倾听请求」练习"},
{"phase": "本月", "focus": "表达后为自己设计一个调节行动"},
},
FAQ: []map[string]string{
{"q": "对方说我太敏感?", "a": "敏感是信息;把它翻译成具体需要,对方更容易配合。"},
},
}
case "B":
return pack{
Label: "安抚稳定型", ShareLine: "我的情感模式:安抚稳定型",
Summary: "你倾向先稳定场面与身心,再处理问题。记得稳定之后仍要把需求说清楚。",
Overview: "你擅长降温:深呼吸、抽离、安慰自己或他人。若只停在安抚、不进入表达与边界,问题可能反复出现。",
Dimensions: []map[string]interface{}{
{"title": "稳定力", "score": 90, "note": "善于降温"},
{"title": "表达力度", "score": 58, "note": "可加强具体诉求"},
{"title": "长期边界", "score": 62, "note": "防重复消耗"},
},
Strengths: []string{"冲突降温", "给人安全感", "自我安抚能力"},
Watchouts: []string{"压抑成真沉默", "问题被搁置", "被当成情绪垃圾桶还不设限"},
Tips: []string{"降温后补一句需求", "重复消耗的情境要设限", "用日记区分安抚与回避"},
Scripts: []string{"我先让自己稳一下,20 分钟后我们继续谈。", "我已经平静些了,我需要讨论的是……"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "安抚后至少表达一个具体需要"},
{"phase": "本月", "focus": "为反复场景写一条边界"},
},
FAQ: []map[string]string{
{"q": "是不是太忍?", "a": "稳定是能力;忍到失语就变成损耗。稳完要说。"},
},
}
default:
label := fallback
if label == "" {
label = "行动调节型"
}
return pack{
Label: label, ShareLine: "我的情感模式:" + label,
Summary: "你习惯用行动(做事、运动、推进)调节情绪。有效,但也要留下命名感受的空间。",
Overview: "行动能快速改变状态,是你的优势。若只用行动回避感受,亲密关系里的人可能觉得你「只顾做事」。行动前后各留一句感受命名。",
Dimensions: []map[string]interface{}{
{"title": "行动力", "score": 88, "note": "用做来调节"},
{"title": "感受命名", "score": 60, "note": "可加强语言化"},
{"title": "关系同步", "score": 64, "note": "行动前告知动机"},
},
Strengths: []string{"恢复快", "不沉溺反刍", "执行导向"},
Watchouts: []string{"跳过感受", "被看作冷漠", "用忙碌麻痹"},
Tips: []string{"行动前说清「我在用行动调节」", "每天写一句情绪命名", "重要关系里先连接再解决"},
Scripts: []string{"我有点烦,我想先去走走,回来再聊。", "我不是不理你,我在用行动让自己回来。"},
GrowthPlan: []map[string]string{
{"phase": "本周", "focus": "行动前后各做一次感受命名"},
{"phase": "本月", "focus": "一次冲突里先连接再给方案"},
},
FAQ: []map[string]string{
{"q": "伴侣说我不沟通?", "a": "把行动翻译成语言:你在调节,以及你何时回来继续谈。"},
},
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// Package scale holds exploration-test scoring helpers.
package scale
// ScoreMajority picks the most frequent answer key and maps to a label set.
func ScoreMajority(answers map[string]string, labels map[string]string, defaultLabel string) (styleKey, label string) {
counts := map[string]int{}
for _, v := range answers {
counts[v]++
}
best, bestN := "", -1
for k, n := range counts {
if n > bestN {
best, bestN = k, n
}
}
if best == "" {
return "", defaultLabel
}
label = labels[best]
if label == "" {
label = defaultLabel
}
return best, label
}
// CommunicationLabels for communication-style scale.
func CommunicationLabels() map[string]string {
return map[string]string{
"A": "理性澄清型",
"B": "感受连接型",
"C": "节奏尊重型",
}
}
// EmotionLabels for emotion-pattern scale.
func EmotionLabels() map[string]string {
return map[string]string{
"A": "觉察表达型",
"B": "安抚稳定型",
"C": "行动调节型",
}
}
+39
View File
@@ -0,0 +1,39 @@
package scale
import "testing"
func TestScoreMajority_communication(t *testing.T) {
key, label := ScoreMajority(map[string]string{
"q1": "B", "q2": "B", "q3": "A",
}, CommunicationLabels(), "平衡探索型")
if key != "B" || label != "感受连接型" {
t.Fatalf("got %s %s", key, label)
}
}
func TestScoreMajority_emotion(t *testing.T) {
key, label := ScoreMajority(map[string]string{
"q1": "C", "q2": "C", "q3": "C",
}, EmotionLabels(), "平衡探索型")
if key != "C" || label != "行动调节型" {
t.Fatalf("got %s %s", key, label)
}
}
func TestScoreMajority_empty(t *testing.T) {
_, label := ScoreMajority(nil, CommunicationLabels(), "平衡探索型")
if label != "平衡探索型" {
t.Fatalf("got %s", label)
}
}
func TestBuildResult_rich(t *testing.T) {
r := BuildResult("communication-style", "B", "感受连接型")
if r["overview"] == nil || r["tips"] == nil || r["scripts"] == nil {
t.Fatalf("missing rich fields: %#v", r)
}
r2 := BuildResult("emotion-pattern", "A", "觉察表达型")
if r2["growth_plan"] == nil || r2["faq"] == nil {
t.Fatalf("missing emotion rich fields")
}
}
+245
View File
@@ -0,0 +1,245 @@
package ask
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
eng "github.com/yuxingu/digital-psychology/apps/api/internal/ask"
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// FreeQuota is the number of assistant replies allowed without membership.
const FreeQuota = 3
const historyLimit = 10
// Service handles ask threads, quota, and replies.
type Service struct {
Profiles *repository.ProfileRepo
Reports *repository.ReportRepo
Ask *repository.AskRepo
LLM *deepseek.Client // optional; nil or disabled → rule engine
}
// CreateThreadInput for POST /ask/threads.
type CreateThreadInput struct {
ProfileID uuid.UUID
Scene string
}
// CreateThread binds a conversation to an owned profile.
func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateThreadInput) (*model.AskThread, error) {
if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil {
return nil, errors.New("profile not found")
}
var scene *string
if sc := strings.TrimSpace(in.Scene); sc != "" {
scene = &sc
}
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
}
// QuotaStatus describes remaining ask allowance.
type QuotaStatus struct {
ActiveMembership bool `json:"active_membership"`
Remaining int `json:"remaining"`
FreeLimit int `json:"free_limit"`
Source string `json:"source"` // membership | free
}
// GetQuota returns remaining ask replies.
func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) {
vip, err := s.Reports.HasActiveMembership(ctx, userID)
if err != nil {
return nil, err
}
if vip {
me, err := s.Reports.GetMembership(ctx, userID)
if err != nil {
return nil, err
}
return &QuotaStatus{
ActiveMembership: true,
Remaining: me.AskQuotaLeft,
FreeLimit: FreeQuota,
Source: "membership",
}, nil
}
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
if err != nil {
return nil, err
}
left := FreeQuota - used
if left < 0 {
left = 0
}
return &QuotaStatus{
ActiveMembership: false,
Remaining: left,
FreeLimit: FreeQuota,
Source: "free",
}, nil
}
// ListMessages returns history for an owned thread.
func (s *Service) ListMessages(ctx context.Context, userID, threadID uuid.UUID) ([]model.AskMessage, error) {
if _, err := s.Ask.GetThreadForUser(ctx, userID, threadID); err != nil {
return nil, errors.New("thread not found")
}
return s.Ask.ListMessages(ctx, threadID)
}
// SendResult is the user+assistant turn after a send.
type SendResult struct {
UserMessage *model.AskMessage `json:"user_message"`
AssistantMessage *model.AskMessage `json:"assistant_message"`
Quota *QuotaStatus `json:"quota"`
}
// SendMessage stores user content, consumes quota, generates assistant reply.
func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, content string) (*SendResult, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("content required")
}
if len([]rune(content)) > 2000 {
return nil, errors.New("content too long")
}
thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
if err != nil {
return nil, errors.New("thread not found")
}
profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID)
if err != nil {
return nil, errors.New("profile not found")
}
quota, err := s.GetQuota(ctx, userID)
if err != nil {
return nil, err
}
if quota.Remaining <= 0 {
return nil, ErrQuotaExhausted
}
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
if err != nil {
return nil, err
}
scene := ""
if thread.Scene != nil {
scene = *thread.Scene
}
hist, _ := s.Ask.ListMessages(ctx, threadID)
reply := s.generateReply(ctx, profile, scene, content, hist)
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
if err != nil {
return nil, err
}
if quota.ActiveMembership {
if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil {
return nil, err
} else if !ok {
// race
}
}
q2, err := s.GetQuota(ctx, userID)
if err != nil {
return nil, err
}
return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil
}
func (s *Service) generateReply(ctx context.Context, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
fallback := eng.BuildReply(eng.ReplyInput{
DisplayName: profile.DisplayName,
BirthDate: profile.BirthDate,
Relation: profile.Relation,
Scene: scene,
UserMessage: userContent,
})
if s.LLM == nil || !s.LLM.Enabled() {
return fallback
}
msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}}
// history excluding the just-inserted user message duplicate handling: include prior + current user
start := 0
if len(hist) > historyLimit*2 {
start = len(hist) - historyLimit*2
}
for _, m := range hist[start:] {
role := m.Role
if role != "user" && role != "assistant" {
continue
}
msgs = append(msgs, deepseek.Message{Role: role, Content: m.Content})
}
// hist already contains the new user message from InsertMessage
out, err := s.LLM.Chat(ctx, msgs)
if err != nil {
log.Printf("ask: deepseek failed, fallback to rules: %v", err)
return fallback
}
if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") {
out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
}
return out
}
func systemPrompt(profile *model.Profile, scene string) string {
name := profile.DisplayName
if name == "" {
if profile.Relation == "other" {
name = "TA"
} else {
name = "你"
}
}
birth := profile.BirthDate.Format("2006-01-02")
rel := "我的档案"
if profile.Relation == "other" {
rel = "TA 的档案"
}
sc := scene
if sc == "" {
sc = "自我探索"
}
return fmt.Sprintf(`你是愈心谷 AI 成长助手了解用户的智能伙伴
定位帮助认识自己理解关系整理情绪与生活节奏像一位细致的成长顾问而不是算命师
禁止算命运势吉凶预测未来改命合盘/合婚话术医疗诊断或疗效承诺恐吓话术
推荐用语了解探索分析建议成长方向生活建议沟通方式情绪调节
当前解读对象%s%s生日 %s场景倾向%s
请用中文做有结构的详细回复 280450 建议结构
1先回应用户当下问题23
2结合档案风格做一层分析性格/沟通/关系/情绪/生活节奏中相关的 12
3给出 24 条可执行小建议尽量具体到本周可做
4如合适给一句可直接说出口的对话示例
语气温暖具体不空洞避免鸡汤套话与玄学预测
结尾提醒内容为自我探索与生活方式参考不构成医疗或占卜预测
今天是 %s`, name, rel, birth, sc, time.Now().Format("2006-01-02"))
}
// ErrQuotaExhausted when free or membership ask quota is 0.
var ErrQuotaExhausted = errors.New("ask quota exhausted")
// IsQuotaExhausted reports ErrQuotaExhausted.
func IsQuotaExhausted(err error) bool {
return errors.Is(err, ErrQuotaExhausted)
}
@@ -0,0 +1,63 @@
package companion
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
core "github.com/yuxingu/digital-psychology/apps/api/internal/companion"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// Service exposes solar terms and moods.
type Service struct {
Moods *repository.MoodRepo
}
// TodaySolar returns today's tip.
func (s *Service) TodaySolar() core.SolarTerm {
return core.TodaySolar(time.Now())
}
// SaveMoodInput for POST /moods.
type SaveMoodInput struct {
Score *int
Note *string
Day *time.Time
}
// SaveMood upserts mood for a day (default today).
func (s *Service) SaveMood(ctx context.Context, userID uuid.UUID, in SaveMoodInput) (*model.Mood, error) {
if in.Score != nil && (*in.Score < 1 || *in.Score > 5) {
return nil, errors.New("score must be 1-5")
}
if in.Note != nil {
n := strings.TrimSpace(*in.Note)
if len([]rune(n)) > 200 {
return nil, errors.New("note too long")
}
in.Note = &n
}
day := time.Now()
if in.Day != nil {
day = *in.Day
}
return s.Moods.UpsertToday(ctx, userID, day, in.Score, in.Note)
}
// GetTodayMood returns today's mood or nil when none.
func (s *Service) GetTodayMood(ctx context.Context, userID uuid.UUID) (*model.Mood, error) {
m, err := s.Moods.GetToday(ctx, userID, time.Now())
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return m, nil
}
@@ -0,0 +1,119 @@
package imagecard
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/imagecard"
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
// Service draws image cards with quota + entitlement.
type Service struct {
Profiles *repository.ProfileRepo
Reports *repository.ReportRepo
Quotas *repository.ImageCardRepo
}
// DrawInput is POST /image-cards/draw body.
type DrawInput struct {
Scene string
ProfileID uuid.UUID
Depth bool
}
// DrawResult wraps report + cards.
type DrawResult struct {
Report *model.GrowthReport `json:"report"`
Scene string `json:"scene"`
Cards []imagecard.Card `json:"cards"`
QuotaLeft int `json:"quota_left"`
}
// Scenes returns reflection scenes.
func (s *Service) Scenes() []map[string]string {
return imagecard.Scenes()
}
// Quota returns remaining draws today.
func (s *Service) Quota(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
vip, err := s.Reports.HasActiveMembership(ctx, userID)
if err != nil {
return nil, err
}
left, err := s.Quotas.RemainingToday(ctx, userID, time.Now(), vip)
if err != nil {
return nil, err
}
return map[string]any{
"remaining": left,
"daily_free": repository.DailyFreeLimit(),
"unlimited": vip,
}, nil
}
// Draw consumes quota (unless membership), builds cards, stores report.
func (s *Service) Draw(ctx context.Context, userID uuid.UUID, in DrawInput) (*DrawResult, error) {
p, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID)
if err != nil {
return nil, errors.New("profile not found")
}
vip, err := s.Reports.HasActiveMembership(ctx, userID)
if err != nil {
return nil, err
}
now := time.Now()
var left int
if vip {
left, _ = s.Quotas.RemainingToday(ctx, userID, now, true)
} else {
left, err = s.Quotas.TryConsume(ctx, userID, now)
if err != nil {
return nil, err
}
}
wantDeep := in.Depth && vip
out := imagecard.Draw(userID.String(), in.Scene, wantDeep || in.Depth, now)
// Persist full detail when depth requested; entitlement strips on read.
sum, _ := json.Marshal(out.Summary)
detPayload := out.Detail
if in.Depth && detPayload == nil {
// force 3-card detail generation for unlock-after-pay path
full := imagecard.Draw(userID.String(), in.Scene, true, now)
detPayload = full.Detail
out.Cards = full.Cards
sum, _ = json.Marshal(full.Summary)
}
det, _ := json.Marshal(detPayload)
if detPayload == nil {
det = []byte("{}")
}
rep, err := s.Reports.Create(ctx, userID, p.ID, "image_card", sum, det)
if err != nil {
return nil, err
}
deep, _ := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
rep.HasDeep = deep || vip
if !rep.HasDeep {
rep.Detail = nil
if !wantDeep {
// free single-card: already summary-only
}
}
cards := out.Cards
if !rep.HasDeep && len(cards) > 1 {
cards = cards[:1]
}
return &DrawResult{
Report: rep,
Scene: out.Scene,
Cards: cards,
QuotaLeft: left,
}, nil
}
+53 -1
View File
@@ -3,6 +3,7 @@ package profile
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
@@ -22,6 +23,8 @@ type CreateInput struct {
DisplayName string
BirthDate time.Time
RelationType *string
BirthTime *string
BirthPlace *string
}
// Create stores a profile for the user.
@@ -40,10 +43,59 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
name = "TA"
}
}
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType)
return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
}
// List returns user's profiles.
func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) {
return s.Repo.ListByUser(ctx, userID)
}
// UpdateInput for PATCH /profiles/:id.
type UpdateInput struct {
DisplayName string
BirthDate time.Time
RelationType *string
BirthTime *string
BirthPlace *string
GeoLat *float64
GeoLng *float64
GeoVisible *bool
}
// Update patches an owned profile.
func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in UpdateInput) (*model.Profile, error) {
cur, err := s.Repo.GetForUser(ctx, userID, profileID)
if err != nil {
return nil, errors.New("profile not found")
}
name := strings.TrimSpace(in.DisplayName)
if name == "" {
name = cur.DisplayName
}
birth := in.BirthDate
if birth.IsZero() {
birth = cur.BirthDate
}
rt := in.RelationType
if rt == nil {
rt = cur.RelationType
}
bt := in.BirthTime
if bt == nil {
bt = cur.BirthTime
}
bp := in.BirthPlace
if bp == nil {
bp = cur.BirthPlace
}
return s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
}
// Delete soft-deletes an owned profile.
func (s *Service) Delete(ctx context.Context, userID, profileID uuid.UUID) error {
if _, err := s.Repo.GetForUser(ctx, userID, profileID); err != nil {
return errors.New("profile not found")
}
return s.Repo.SoftDeleteForUser(ctx, userID, profileID)
}
@@ -38,7 +38,11 @@ func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*Crea
if err != nil {
return nil, errors.New("profile_b not found")
}
out := releng.Build(pa.BirthDate, pb.BirthDate, pa.DisplayName, pb.DisplayName)
relType := ""
if pb.RelationType != nil {
relType = *pb.RelationType
}
out := releng.BuildFull(pa.BirthDate, pb.BirthDate, pa.BirthTime, pb.BirthTime, pa.BirthPlace, pb.BirthPlace, pa.DisplayName, pb.DisplayName, relType)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det)
+223
View File
@@ -4,18 +4,123 @@ import (
"context"
"encoding/json"
"errors"
"time"
"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"
"github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
"github.com/yuxingu/digital-psychology/apps/api/internal/star"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
)
// Service creates and reads growth reports with entitlement trimming.
type Service struct {
Profiles *repository.ProfileRepo
Reports *repository.ReportRepo
Invites *repository.SynastryInviteRepo
}
// Nearby lists geo-visible self profiles of other users within radius.
func (s *Service) Nearby(ctx context.Context, userID uuid.UUID, lat, lng, radiusKm float64) ([]repository.NearbyItem, error) {
if s.Profiles == nil {
return nil, errors.New("profiles unavailable")
}
if radiusKm <= 0 {
radiusKm = 50
}
if radiusKm > 200 {
radiusKm = 200
}
return s.Profiles.ListNearby(ctx, userID, lat, lng, radiusKm, 20)
}
// CreateInvite creates a shareable synastry invite for host profile.
func (s *Service) CreateInvite(ctx context.Context, userID, hostProfileID uuid.UUID) (*model.SynastryInvite, error) {
if s.Invites == nil {
return nil, errors.New("invites unavailable")
}
if _, err := s.Profiles.GetForUser(ctx, userID, hostProfileID); err != nil {
return nil, errors.New("profile not found")
}
return s.Invites.Create(ctx, userID, hostProfileID)
}
// GetInvite returns invite by token (public metadata for landing page).
func (s *Service) GetInvite(ctx context.Context, token string) (map[string]any, error) {
if s.Invites == nil {
return nil, errors.New("invites unavailable")
}
inv, err := s.Invites.GetByToken(ctx, token)
if err != nil {
return nil, errors.New("invite not found")
}
if time.Now().After(inv.ExpiresAt) {
return nil, errors.New("invite expired")
}
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
if err != nil {
return nil, errors.New("host profile missing")
}
return map[string]any{
"token": inv.Token,
"expires_at": inv.ExpiresAt,
"host_name": host.DisplayName,
"already_accepted": inv.ReportID != nil,
}, nil
}
// AcceptInvite creates guest other-profile for guest user, builds synastry, marks invite (atomic).
func (s *Service) AcceptInvite(ctx context.Context, guestUser uuid.UUID, token, displayName, birthDate string, birthTime, birthPlace *string) (*model.GrowthReport, error) {
if s.Invites == nil {
return nil, errors.New("invites unavailable")
}
inv, err := s.Invites.GetByToken(ctx, token)
if err != nil {
return nil, errors.New("invite not found")
}
if time.Now().After(inv.ExpiresAt) {
return nil, errors.New("invite expired")
}
if inv.ReportID != nil {
return nil, errors.New("invite already used")
}
if inv.HostUserID == guestUser {
return nil, errors.New("不能接受自己的邀请")
}
birth, err := time.Parse("2006-01-02", birthDate)
if err != nil {
return nil, errors.New("birth_date must be YYYY-MM-DD")
}
name := displayName
if name == "" {
name = "TA"
}
host, err := s.Profiles.GetByID(ctx, inv.HostProfileID)
if err != nil {
return nil, errors.New("host profile missing")
}
ca, err := star.NatalChart(host.BirthDate, host.BirthTime, host.BirthPlace)
if err != nil {
return nil, err
}
cb, err := star.NatalChart(birth, birthTime, birthPlace)
if err != nil {
return nil, err
}
out, err := synastry.BuildReport(ca, cb, host.DisplayName, name, time.Now())
if err != nil {
return nil, err
}
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Invites.AcceptAtomic(ctx, inv.ID, guestUser, name, birth, birthTime, birthPlace, sum, det)
if err != nil {
return nil, err
}
return s.applyEntitlement(ctx, guestUser, rep)
}
// CreatePortrait builds and stores a portrait report.
@@ -34,6 +139,83 @@ func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUI
return s.applyEntitlement(ctx, userID, rep)
}
// CreateStar builds and stores a 星象性格 report.
func (s *Service) CreateStar(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, err := star.BuildWith(star.BuildOpts{
Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
})
if err != nil {
return nil, err
}
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, profileID, "star", sum, det)
if err != nil {
return nil, err
}
return s.applyEntitlement(ctx, userID, rep)
}
// CreateSynastry builds a multi-chart synastry report for two profiles.
// asOf is the secondary-progression date (defaults to today CST when nil/zero).
func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profileBID uuid.UUID, asOf *time.Time) (*model.GrowthReport, error) {
pa, err := s.Profiles.GetForUser(ctx, userID, profileAID)
if err != nil {
return nil, errors.New("profile a not found")
}
pb, err := s.Profiles.GetForUser(ctx, userID, profileBID)
if err != nil {
// Allow geo-visible self profiles of other users (附近的人).
pb, err = s.Profiles.GetByID(ctx, profileBID)
if err != nil || !pb.GeoVisible || pb.Relation != "self" || pb.UserID == userID {
return nil, errors.New("profile b not found")
}
}
ca, err := star.NatalChart(pa.BirthDate, pa.BirthTime, pa.BirthPlace)
if err != nil {
return nil, err
}
cb, err := star.NatalChart(pb.BirthDate, pb.BirthTime, pb.BirthPlace)
if err != nil {
return nil, err
}
when := time.Now().In(time.FixedZone("CST", 8*3600))
if asOf != nil && !asOf.IsZero() {
when = *asOf
}
out, err := synastry.BuildReport(ca, cb, pa.DisplayName, pb.DisplayName, when)
if err != nil {
return nil, err
}
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, profileAID, "synastry", sum, det)
if err != nil {
return nil, err
}
return s.applyEntitlement(ctx, userID, rep)
}
// CreateRhythm builds and stores a 身心节律 report.
func (s *Service) CreateRhythm(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 := rhythm.Build(p.BirthDate, p.DisplayName)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
rep, err := s.Reports.Create(ctx, userID, profileID, "rhythm", 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)
@@ -43,6 +225,23 @@ func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.G
return s.applyEntitlement(ctx, userID, rep)
}
// List returns recent reports with entitlement trimming.
func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]*model.GrowthReport, error) {
items, err := s.Reports.ListForUser(ctx, userID, 50)
if err != nil {
return nil, err
}
out := make([]*model.GrowthReport, 0, len(items))
for i := range items {
rep, err := s.applyEntitlement(ctx, userID, &items[i])
if err != nil {
return nil, err
}
out = append(out, rep)
}
return out, nil
}
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 {
@@ -85,3 +284,27 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr
func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error {
return s.Reports.PayMock(ctx, userID, orderID)
}
// MembershipMe is the public membership snapshot.
type MembershipMe struct {
Active bool `json:"active"`
Plan string `json:"plan,omitempty"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
AskQuotaLeft int `json:"ask_quota_left,omitempty"`
}
// GetMembership returns current growth membership for the user.
func (s *Service) GetMembership(ctx context.Context, userID uuid.UUID) (*MembershipMe, error) {
row, err := s.Reports.GetMembership(ctx, userID)
if err != nil {
return nil, err
}
return &MembershipMe{
Active: row.Active,
Plan: row.Plan,
Status: row.Status,
ExpiresAt: row.ExpiresAt,
AskQuotaLeft: row.AskQuotaLeft,
}, nil
}
+39 -25
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
sc "github.com/yuxingu/digital-psychology/apps/api/internal/scale"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
@@ -51,31 +52,10 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in
if err != nil {
return nil, errors.New("scale not found")
}
counts := map[string]int{}
for _, v := range in.Answers {
counts[v]++
}
best, bestN := "A", -1
for k, n := range counts {
if n > bestN {
best, bestN = k, n
}
}
label := map[string]string{
"A": "理性澄清型",
"B": "感受连接型",
"C": "节奏尊重型",
}[best]
if label == "" {
label = "平衡探索型"
}
result := map[string]interface{}{
"title": "探索结果",
"style_key": best,
"label": label,
"summary": "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。",
"share_line": "我的沟通方式:" + label,
}
labels, _, _ := labelsForSlug(slug)
key, label := sc.ScoreMajority(in.Answers, labels, "平衡探索型")
result := sc.BuildResult(slug, key, label)
ansJSON, _ := json.Marshal(in.Answers)
resJSON, _ := json.Marshal(result)
id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON)
@@ -84,3 +64,37 @@ func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in
}
return &SubmitResult{ID: id, Result: result}, nil
}
func labelsForSlug(slug string) (labels map[string]string, sharePrefix, summary string) {
switch slug {
case "emotion-pattern":
return sc.EmotionLabels(),
"我的情感模式:",
"这是你当前情绪调节偏好的探索结果,可用于自我了解与日常调整,不是固定标签。"
case "mbti-lite":
return map[string]string{"E": "外向充能型", "I": "内向充能型", "T": "理性决策型", "F": "感受决策型", "J": "结构安排型", "P": "弹性探索型"},
"我的人格偏好:", "轻量人格偏好探索,不是固定分类。"
case "enneagram-lite":
return map[string]string{"A": "尽责驱动型", "B": "联结驱动型", "C": "独立驱动型"},
"我的动机模式:", "内在动机轻量探索,用于自我觉察。"
case "bigfive-lite":
return map[string]string{"O": "开放探索型", "C": "条理稳健型", "E": "主动外展型", "A": "温和协作型", "N": "细腻敏感型"},
"我的性格五维:", "稳定特质速览,可随情境变化。"
case "love-style":
return map[string]string{"A": "稳定陪伴型", "B": "深度共鸣型", "C": "空间尊重型"},
"我的亲密互动:", "亲密关系偏好探索,不是关系定论。"
case "eq-lite":
return map[string]string{"A": "觉察表达型", "B": "行动调节型", "C": "缓慢识别型"},
"我的情绪觉察:", "情绪习惯探索,可用于日常调节。"
case "stress-index":
return map[string]string{"A": "节奏稳定型", "B": "波动调节型", "C": "高负荷需休息型"},
"我的压力负荷:", "近期压力与恢复偏好,不是诊断。"
case "career-interest":
return map[string]string{"A": "创造表达型", "B": "助人支持型", "C": "分析解决型"},
"我的职业兴趣:", "工作动力偏好探索,可作方向参考。"
default:
return sc.CommunicationLabels(),
"我的沟通方式:",
"这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。"
}
}
+337
View File
@@ -0,0 +1,337 @@
// Package star builds 星座 reports (natal chart · fortune · deep copy).
package star
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/fortune"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Output is free summary + gated detail.
type Output struct {
Summary map[string]any `json:"summary"`
Detail map[string]any `json:"detail"`
}
// BuildOpts configures report generation.
type BuildOpts struct {
Birth time.Time
BirthTime *string
BirthPlace *string
Name string
AsOf time.Time // fortune anchor; zero = now
}
// Build generates StarProfile from birth date (compat wrapper).
func Build(birth time.Time, birthTime *string, displayName string) (Output, error) {
return BuildWith(BuildOpts{Birth: birth, BirthTime: birthTime, Name: displayName})
}
// BuildWith generates a full star report.
func BuildWith(opts BuildOpts) (Output, error) {
name := opts.Name
if name == "" {
name = "你"
}
chart, err := natal.Compute(opts.Birth, opts.BirthTime, opts.BirthPlace)
if err != nil {
return Output{}, err
}
sun := chart.Sun
moon := chart.Moon
rise := chart.Rise
pack := packs[sun.SignKey]
if pack.Label == "" {
pack = defaultPack(signMeta{Key: sun.SignKey, Label: sun.Sign, Element: sun.Element, Modality: sun.Modality})
}
moonPack := packs[moon.SignKey]
risePack := packs[rise.SignKey]
signCards := []map[string]any{
signCard("sun", "太阳星座", sun, pack.SunTeaser, pack.Keywords),
signCard("moon", "月亮星座", moon, fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), moonPack.Keywords),
signCard("rise", "上升星座", rise, fmt.Sprintf("第一印象更偏「%s」", rise.Sign), risePack.Keywords),
}
dims := []map[string]any{
{"key": "sun", "title": "太阳风格", "teaser": pack.SunTeaser, "score": pack.Scores["sun"]},
{"key": "moon", "title": "月亮节奏", "teaser": fmt.Sprintf("情绪底色更偏「%s」", moon.Sign), "score": pack.Scores["moon"]},
{"key": "rise", "title": "上升表达", "teaser": fmt.Sprintf("第一印象更偏「%s」", rise.Sign), "score": pack.Scores["rise"]},
{"key": "relation", "title": "关系互动", "teaser": pack.RelationTeaser, "score": pack.Scores["relation"]},
{"key": "career", "title": "事业节奏", "teaser": pack.CareerTeaser, "score": pack.Scores["career"]},
{"key": "growth", "title": "成长方向", "teaser": pack.GrowthTeaser, "score": pack.Scores["growth"]},
}
asOf := opts.AsOf
if asOf.IsZero() {
asOf = time.Now()
}
fort := fortune.Build(chart, asOf)
daily := fort.Daily
planetsOut := make([]map[string]any, 0, len(chart.Planets))
for _, p := range chart.Planets {
planetsOut = append(planetsOut, map[string]any{
"key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey,
"degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House,
"element": p.Element, "modality": p.Modality, "lon": p.Lon,
})
}
housesOut := make([]map[string]any, 0, len(chart.Houses))
for _, h := range chart.Houses {
// Whole-sign house cusp ≈ asc sign start + (n-1)*30
cusp := float64((signIndexOf(h.Key)) * 30)
housesOut = append(housesOut, map[string]any{
"num": h.Num, "sign": h.Sign, "sign_key": h.Key, "cusp_lon": cusp,
})
}
aspects := natal.Aspects(chart)
aspectMaps := natal.AspectsAsMaps(aspects)
previewN := min(4, len(aspectMaps))
aspectPreview := aspectMaps[:previewN]
summary := map[string]any{
"title": "星座·基础",
"style_label": pack.Label,
"headline": fmt.Sprintf("%s的星座更偏「%s」", name, pack.Label),
"one_liner": pack.OneLiner,
"overview": fmt.Sprintf("%s%s(太阳:%s;月亮:%s;上升:%s)。", name, pack.Overview, sun.Sign, moon.Sign, rise.Sign),
"life_tip": pack.LifeTip,
"keywords": pack.Keywords,
"dimensions": dims,
"sign_cards": signCards,
"sun_sign": sun.Sign,
"moon_sign": moon.Sign,
"rise_sign": rise.Sign,
"chart": map[string]any{
"note": chart.Note, "place": chart.PlaceLabel,
"has_time": chart.HasTime, "has_place": chart.HasPlace,
"lat": chart.Lat, "lng": chart.Lng,
"asc_lon": rise.Lon, "houses": housesOut,
},
"planets": planetsOut,
"aspects_preview": aspectPreview,
"fortune": fort.AsMap(),
"transits": fort.AsMap()["transits"],
"daily_soft": map[string]any{
"title": daily.Title, "focus": daily.Focus, "tip": daily.Tip,
"energy": daily.Score, "note": daily.Label, "score": daily.Score, "label": daily.Label,
},
"strengths_preview": pack.Strengths[:min(3, len(pack.Strengths))],
"blind_spots_preview": []string{"完整相位与年运详解见深度版。"},
"interaction_tags": []string{
fmt.Sprintf("太阳·%s", sun.Sign),
fmt.Sprintf("月亮·%s", moon.Sign),
fmt.Sprintf("上升·%s", rise.Sign),
pack.Label,
},
}
aspectBullets := make([]string, 0, min(8, len(aspects)))
for i, a := range aspects {
if i >= 8 {
break
}
aspectBullets = append(aspectBullets, a.Label)
}
detail := map[string]any{
"title": "星座·完整分析",
"aspects": aspectMaps,
"sections": []map[string]any{
section("太阳星座 · "+sun.Sign, pack.SunDeep, pack.SunBullets),
section("月亮星座 · "+moon.Sign, fmt.Sprintf("情绪底色带有「%s」特质:%s", moon.Sign, moonPack.MoonDeep), moonPack.MoonBullets),
section("上升星座 · "+rise.Sign, fmt.Sprintf("对外第一印象偏「%s」:%s", rise.Sign, risePack.RiseDeep), risePack.RiseBullets),
section("行星相位要点", "主要相位帮助理解行动、情感与责任节奏之间的张力与互补。", aspectBullets),
section("行星落座", "落座与宫位是日常节奏的参照。", planetBullets(chart)),
section("年运详解", fort.Yearly.Tip+" "+fort.Yearly.Caution, []string{
fmt.Sprintf("综合分 %d%s", fort.Yearly.Score, fort.Yearly.Label),
fmt.Sprintf("感情 %d · 事业 %d · 财务 %d · 心情 %d", fort.Yearly.Dims["love"], fort.Yearly.Dims["career"], fort.Yearly.Dims["money"], fort.Yearly.Dims["mood"]),
fort.Yearly.Lucky,
}),
section("一生运势", fort.Lifetime.Tip, []string{fort.Lifetime.Caution, fort.Lifetime.Lucky}),
section("关系互动", pack.RelationDeep, pack.RelationBullets),
section("事业与学习节奏", pack.CareerDeep, pack.CareerBullets),
section("成长方向", pack.GrowthDeep, pack.GrowthBullets),
},
"strengths": pack.Strengths,
"blind_spots": pack.BlindSpots,
"growth_plan": []map[string]any{
{"phase": "本周", "focus": pack.PlanWeek},
{"phase": "本月", "focus": pack.PlanMonth},
{"phase": "长期", "focus": pack.PlanLong},
},
"conversation_scripts": pack.Scripts,
"faq": pack.FAQ,
"behavior_pattern": pack.SunDeep,
"relation_style": pack.RelationDeep,
"growth_direction": pack.GrowthDeep,
"fortune_detail": fort.AsMap(),
}
return Output{Summary: summary, Detail: detail}, nil
}
func signIndexOf(key string) int {
for i, s := range []string{
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
} {
if s == key {
return i
}
}
return 0
}
func planetBullets(chart natal.Chart) []string {
out := make([]string, 0, 6)
for _, key := range []string{"mercury", "venus", "mars", "jupiter", "saturn"} {
for _, p := range chart.Planets {
if p.Key == key {
out = append(out, fmt.Sprintf("%s在%s第%d宫", p.Title, p.Sign, p.House))
break
}
}
}
return out
}
func signCard(key, title string, b natal.Body, teaser string, keywords []string) map[string]any {
ks := keywords
if len(ks) > 3 {
ks = ks[:3]
}
return map[string]any{
"key": key, "title": title, "label": b.Sign,
"element": b.Element, "modality": b.Modality,
"teaser": teaser, "keywords": ks,
}
}
func section(title, body string, bullets []string) map[string]any {
return map[string]any{"title": title, "body": body, "bullets": bullets}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// SignLabels returns sun/moon/rise labels (uses natal engine).
func SignLabels(birth time.Time, birthTime *string) (sun, moon, rise string) {
return SignLabelsPlace(birth, birthTime, nil)
}
// SignLabelsPlace includes birth place for rising accuracy.
func SignLabelsPlace(birth time.Time, birthTime, place *string) (sun, moon, rise string) {
c, err := natal.Compute(birth, birthTime, place)
if err != nil {
return "", "", ""
}
return c.Sun.Sign, c.Moon.Sign, c.Rise.Sign
}
// NatalChart exposes chart for relation/synastry.
func NatalChart(birth time.Time, birthTime, place *string) (natal.Chart, error) {
return natal.Compute(birth, birthTime, place)
}
type signMeta struct {
Key, Label, Element, Modality string
}
type pack struct {
Label, OneLiner, Overview, LifeTip string
Keywords []string
Scores map[string]int
SunTeaser, RelationTeaser, CareerTeaser, GrowthTeaser string
SunDeep, MoonDeep, RiseDeep, RelationDeep, CareerDeep, GrowthDeep string
SunBullets, MoonBullets, RiseBullets, RelationBullets, CareerBullets, GrowthBullets []string
Strengths, BlindSpots, Scripts []string
PlanWeek, PlanMonth, PlanLong string
FAQ []map[string]string
}
var packs = map[string]pack{}
func init() {
for _, s := range []signMeta{
{"aries", "白羊", "火", "开创"}, {"taurus", "金牛", "土", "固定"}, {"gemini", "双子", "风", "变动"},
{"cancer", "巨蟹", "水", "开创"}, {"leo", "狮子", "火", "固定"}, {"virgo", "处女", "土", "变动"},
{"libra", "天秤", "风", "开创"}, {"scorpio", "天蝎", "水", "固定"}, {"sagittarius", "射手", "火", "变动"},
{"capricorn", "摩羯", "土", "开创"}, {"aquarius", "水瓶", "风", "固定"}, {"pisces", "双鱼", "水", "变动"},
} {
packs[s.Key] = defaultPack(s)
}
packs["aries"] = enrich(packs["aries"], "开创行动者", "先动起来,再在行动里想清楚。",
"你容易被新目标点燃,讨厌拖沓。优势是启动快;需要留意的是收尾与倾听。")
packs["taurus"] = enrich(packs["taurus"], "稳健沉淀者", "你重视踏实与感官舒适,变化太快会消耗你。",
"你擅长把事情做稳做久。关系与工作里都需要可预期的节奏。")
packs["gemini"] = enrich(packs["gemini"], "灵活连接者", "你靠好奇与对话充电,也容易分心。",
"信息与交流是你的养分。把想法收成一个可交付的小闭环,会更有成就感。")
packs["cancer"] = enrich(packs["cancer"], "细腻守护者", "你对情绪与归属很敏感,安全比热闹更重要。",
"你擅长照顾氛围与关系。记得也把自己的需要说清楚,而不是只默默撑着。")
packs["leo"] = enrich(packs["leo"], "热烈表达者", "你需要被看见,也愿意照亮别人。",
"热情与表达是你的名片。把认可需求说成具体请求,关系会更顺。")
packs["virgo"] = enrich(packs["virgo"], "细致完善者", "你看见细节与改进空间,也容易自我要求过高。",
"把「足够好」纳入标准,你会轻松很多,交付也会更快。")
packs["libra"] = enrich(packs["libra"], "平衡协调者", "你追求公平与和谐,有时会为难自己。",
"协调是天赋。重要决定里请给自己一票,而不只是各方折中。")
packs["scorpio"] = enrich(packs["scorpio"], "深潜洞察者", "你看重真诚与深度,讨厌浮于表面。",
"信任慢、一旦建立则很深。练习用语言同步感受,减少猜疑消耗。")
packs["sagittarius"] = enrich(packs["sagittarius"], "开阔探索者", "你需要视野与意义,讨厌被框死。",
"探索欲强。给自由一点结构,热情才能变成持续作品。")
packs["capricorn"] = enrich(packs["capricorn"], "负责攀登者", "你看长远目标,愿意为结果负责。",
"担当是优势。学会求助与休息,攀登才可持续。")
packs["aquarius"] = enrich(packs["aquarius"], "独特思考者", "你重视独立与新意,也需要被理解。",
"独特视角是礼物。把想法翻译成别人跟得上的一步行动。")
packs["pisces"] = enrich(packs["pisces"], "共感想象者", "你感受力强,边界容易被情绪浪潮冲开。",
"共情是天赋。区分「理解」与「承包」,你会更稳。")
}
func defaultPack(s signMeta) pack {
return pack{
Label: s.Label + "风格探索者", OneLiner: "用星座认识自己的节奏与运势起伏。",
Overview: "在星座框架里,你的表达与需求有独特侧重。",
LifeTip: "本周选一件小事完整做完,并告诉亲近的人你的真实需要。",
Keywords: []string{s.Label, s.Element + "象", s.Modality, "星座"},
Scores: map[string]int{"sun": 78, "moon": 70, "rise": 72, "relation": 74, "career": 73, "growth": 75},
SunTeaser: "核心驱动力清晰,行动有自己的节拍。", RelationTeaser: "关系里需要被理解与尊重节奏。",
CareerTeaser: "适合发挥你风格优势的场景。", GrowthTeaser: "下一步是看见盲区并小步调整。",
SunDeep: "太阳星座描述你的核心动机与自我表达。",
MoonDeep: "月亮星座指向情绪调节与安全感来源。",
RiseDeep: "上升星座影响别人对你的第一印象。",
RelationDeep: "关系中把需求说具体,比期待对方「应该懂」更有效。",
CareerDeep: "工作上优先发挥你的风格优势,并用小里程碑对抗拖延。",
GrowthDeep: "成长是认识模式后做可验证的小调整。",
SunBullets: []string{"核心动机可被命名", "表达有风格偏好", "适合自我探索"},
MoonBullets: []string{"情绪需要出口", "安全感来源因人而异", "独处或连接可充电"},
RiseBullets: []string{"第一印象可调节", "外显≠全部自我", "可练习温和表达"},
RelationBullets: []string{"需求具体化", "尊重双方节奏", "冲突先复述再方案"},
CareerBullets: []string{"发挥风格优势", "小步交付", "复盘节奏"},
GrowthBullets: []string{"看见模式", "小步验证", "结合运势调整"},
Strengths: []string{"风格清晰", "可探索性强", "利于自我对话"},
BlindSpots: []string{"标签固化", "忽略情境差异", "过度解读"},
Scripts: []string{"我想先说清我的节奏,再听你的。", "我不是冷淡,我需要一点整理时间。", "我们共同目标是……,下一步只定一件事。"},
PlanWeek: "用三句话写下:我的优势 / 我的消耗点 / 我本周要试的一小步。",
PlanMonth: "在关系或工作中练习两次「先复述对方,再提需要」。",
PlanLong: "建立个人节奏手册:什么充电、什么耗电、如何请求支持。",
FAQ: []map[string]string{
{"q": "运势是预测吗?", "a": "运势分与建议帮助你调整节奏与决策,请结合现实判断,勿作唯一依据。"},
{"q": "星盘准吗?", "a": "出生时与出生地越完整,上升与宫位越贴近;算法为可复现近似星历。"},
},
}
}
func enrich(base pack, label, one, overview string) pack {
base.Label = label
base.OneLiner = one
base.Overview = overview
base.Keywords = []string{label, "星座", "运势"}
return base
}
+83
View File
@@ -0,0 +1,83 @@
package star
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestBuildDeterministic(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
asOf := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
opts := BuildOpts{Birth: birth, Name: "小愈", AsOf: asOf}
a, err := BuildWith(opts)
if err != nil {
t.Fatal(err)
}
b, err := BuildWith(opts)
if err != nil {
t.Fatal(err)
}
if a.Summary["headline"] != b.Summary["headline"] {
t.Fatal("not deterministic")
}
if a.Summary["sun_sign"] == nil || a.Detail["sections"] == nil {
t.Fatal("missing fields")
}
if a.Summary["sign_cards"] == nil || a.Summary["fortune"] == nil || a.Summary["planets"] == nil {
t.Fatal("missing sign_cards, fortune or planets")
}
if a.Summary["aspects_preview"] == nil {
t.Fatal("missing aspects_preview")
}
chart, _ := a.Summary["chart"].(map[string]any)
if chart["asc_lon"] == nil {
t.Fatal("missing chart.asc_lon")
}
if a.Detail["aspects"] == nil {
t.Fatal("missing detail.aspects")
}
raw, _ := json.Marshal(a)
blob := string(raw)
for _, bad := range []string{"算命"} {
if strings.Contains(blob, bad) {
t.Fatalf("forbidden %q", bad)
}
}
}
func TestBuildWithBirthTimeChangesRise(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
bt := "14:30"
place := "北京"
a, err := BuildWith(BuildOpts{Birth: birth, Name: "我"})
if err != nil {
t.Fatal(err)
}
b, err := BuildWith(BuildOpts{Birth: birth, BirthTime: &bt, BirthPlace: &place, Name: "我"})
if err != nil {
t.Fatal(err)
}
if a.Summary["planets"] == nil || b.Summary["planets"] == nil {
t.Fatal("missing planets")
}
_ = a.Summary["rise_sign"]
_ = b.Summary["rise_sign"]
}
func TestBuildFortuneScores(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
out, err := BuildWith(BuildOpts{Birth: birth, Name: "测", AsOf: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)})
if err != nil {
t.Fatal(err)
}
fort, ok := out.Summary["fortune"].(map[string]any)
if !ok {
t.Fatal("fortune missing")
}
daily, ok := fort["daily"].(map[string]any)
if !ok || daily["score"] == nil {
t.Fatal("daily score missing")
}
}
@@ -0,0 +1,161 @@
// Package ephemeris computes tropical ecliptic longitudes.
// Default backend: Swiss Ephemeris Moshier (no external ephe files).
// NOTE: go-swisseph is AGPL-3.0; commercial deployment needs Astrodienst SE license or AGPL compliance.
package ephemeris
import (
"fmt"
"math"
"os"
"sync"
"time"
swe "github.com/tejzpr/go-swisseph"
)
// Body keys match natal chart keys.
const (
BodySun = "sun"
BodyMoon = "moon"
BodyMercury = "mercury"
BodyVenus = "venus"
BodyMars = "mars"
BodyJupiter = "jupiter"
BodySaturn = "saturn"
BodyUranus = "uranus"
BodyNeptune = "neptune"
BodyPluto = "pluto"
)
var bodyID = map[string]int32{
BodySun: swe.Sun,
BodyMoon: swe.Moon,
BodyMercury: swe.Mercury,
BodyVenus: swe.Venus,
BodyMars: swe.Mars,
BodyJupiter: swe.Jupiter,
BodySaturn: swe.Saturn,
BodyUranus: swe.Uranus,
BodyNeptune: swe.Neptune,
BodyPluto: swe.Pluto,
}
// PlanetOrder is the standard body sequence (excluding ASC).
var PlanetOrder = []string{
BodySun, BodyMoon, BodyMercury, BodyVenus, BodyMars,
BodyJupiter, BodySaturn, BodyUranus, BodyNeptune, BodyPluto,
}
var (
once sync.Once
iflag int32 = swe.FlagMoseph
epheOK bool
)
func initSE() {
once.Do(func() {
path := os.Getenv("SE_EPHE_PATH")
if path == "" {
path = os.Getenv("EPHEMERIS_PATH")
}
if path != "" {
swe.SetEphePath(path)
iflag = swe.FlagSwieph
epheOK = true
} else {
// Moshier: no files required
iflag = swe.FlagMoseph
epheOK = true
}
})
}
// Backend returns "moshier" or "swiss" for diagnostics.
func Backend() string {
initSE()
if iflag == swe.FlagSwieph {
return "swiss"
}
return "moshier"
}
// JulianDayUT converts a UTC instant to Julian Day (UT).
func JulianDayUT(utc time.Time) float64 {
initSE()
utc = utc.UTC()
sec := float64(utc.Second()) + float64(utc.Nanosecond())/1e9
dret, err := swe.UtcToJd(
int32(utc.Year()), int32(utc.Month()), int32(utc.Day()),
int32(utc.Hour()), int32(utc.Minute()), sec, swe.GregCal,
)
if err != nil {
// Fallback Meeus-style JD
return julianDayFallback(utc)
}
return dret[1] // UT
}
// PlanetLon returns tropical ecliptic longitude in degrees [0,360).
func PlanetLon(jdUt float64, body string) (float64, error) {
initSE()
ipl, ok := bodyID[body]
if !ok {
return 0, fmt.Errorf("unknown body %q", body)
}
r := swe.CalcUT(jdUt, ipl, iflag)
if r.Flag < 0 {
return 0, fmt.Errorf("swe calc %s: %s", body, r.Error)
}
return norm360(r.Data[0]), nil
}
// Ascendant returns tropical ASC longitude degrees using Placidus cusps (Points[0]).
// Callers apply Whole Sign houses from this ASC.
func Ascendant(jdUt, lat, lng float64) (float64, error) {
initSE()
h := swe.Houses(jdUt, lat, lng, 'P')
if h.Flag < 0 || len(h.Points) < 1 {
return 0, fmt.Errorf("swe houses failed flag=%d", h.Flag)
}
return norm360(h.Points[0]), nil
}
// AllPlanetLons returns longitudes for PlanetOrder.
func AllPlanetLons(jdUt float64) (map[string]float64, error) {
out := make(map[string]float64, len(PlanetOrder))
for _, k := range PlanetOrder {
lon, err := PlanetLon(jdUt, k)
if err != nil {
return nil, err
}
out[k] = lon
}
return out, nil
}
func norm360(x float64) float64 {
x = math.Mod(x, 360)
if x < 0 {
x += 360
}
return x
}
func julianDayFallback(t time.Time) float64 {
y := t.Year()
m := int(t.Month())
d := float64(t.Day()) + float64(t.Hour())/24 + float64(t.Minute())/1440 + float64(t.Second())/86400
if m <= 2 {
y--
m += 12
}
A := y / 100
B := 2 - A + A/4
return math.Floor(365.25*float64(y+4716)) + math.Floor(30.6001*float64(m+1)) + d + float64(B) - 1524.5
}
// Ready reports whether ephemeris init succeeded (always true for Moshier).
func Ready() bool {
initSE()
return epheOK
}
@@ -0,0 +1,44 @@
package ephemeris
import (
"testing"
"time"
)
func TestSunMay1990(t *testing.T) {
if !Ready() {
t.Fatal("ephemeris not ready")
}
// 1990-05-12 10:30 CST = 02:30 UTC
utc := time.Date(1990, 5, 12, 2, 30, 0, 0, time.UTC)
jd := JulianDayUT(utc)
lon, err := PlanetLon(jd, BodySun)
if err != nil {
t.Fatal(err)
}
// ~51° Taurus
if lon < 48 || lon > 55 {
t.Fatalf("sun lon=%.2f want ~51°", lon)
}
asc, err := Ascendant(jd, 31.23, 121.47)
if err != nil {
t.Fatal(err)
}
if asc < 0 || asc >= 360 {
t.Fatalf("bad asc %.2f", asc)
}
if Backend() == "" {
t.Fatal("empty backend")
}
}
func TestAllPlanetLons(t *testing.T) {
jd := JulianDayUT(time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC))
m, err := AllPlanetLons(jd)
if err != nil {
t.Fatal(err)
}
if len(m) != len(PlanetOrder) {
t.Fatalf("got %d", len(m))
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package fortune synthesizes daily/weekly/monthly/yearly/lifetime scores and transits.
package fortune
import (
"fmt"
"math"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Period is one fortune block.
type Period struct {
Key string `json:"key"`
Title string `json:"title"`
Score int `json:"score"`
Label string `json:"label"`
Dims map[string]int `json:"dims"`
Tip string `json:"tip"`
Lucky string `json:"lucky"`
Caution string `json:"caution"`
Focus string `json:"focus"`
}
// Transit is a simplified day transit tip relative to natal.
type Transit struct {
Key string `json:"key"`
Title string `json:"title"`
Aspect string `json:"aspect"`
Tip string `json:"tip"`
}
// Bundle holds all periods plus lifetime and transits.
type Bundle struct {
Daily Period `json:"daily"`
Weekly Period `json:"weekly"`
Monthly Period `json:"monthly"`
Yearly Period `json:"yearly"`
Lifetime Period `json:"lifetime"`
Transits []Transit `json:"transits"`
}
// Build returns fortune for natal chart as of asOf (date matters).
func Build(chart natal.Chart, asOf time.Time) Bundle {
if asOf.IsZero() {
asOf = time.Now()
}
sun := chart.Sun.Lon
moon := chart.Moon.Lon
daySeed := asOf.Year()*1000 + asOf.YearDay() + int(sun) + int(moon)
daily := period("daily", "今日运势", daySeed, sun, moon, chart,
[]string{"行动", "沟通", "情绪", "财运", "桃花"},
[]string{
fmt.Sprintf("发挥太阳「%s」的主动性,先完成一件小事。", chart.Sun.Sign),
fmt.Sprintf("照顾月亮「%s」的情绪需要,给自己缓冲。", chart.Moon.Sign),
"适合推进沟通:先复述再提方案。",
"宜整理待办与开销,给节奏一点秩序。",
"轻社交或独处充电均可,按电量选择。",
})
weekSeed := asOf.Year()*100 + isoWeek(asOf) + int(sun)/3
weekly := period("weekly", "本周运势", weekSeed, sun, moon, chart,
[]string{"事业", "关系", "学习", "休息", "决策"},
[]string{
"本周适合定一个可交付的小目标并公开承诺。",
"关系上主动约一次轻松同步,不谈对错。",
"学习/复盘:把灵感写成三条行动。",
"安排半日空档恢复精力。",
"重大决定用「可逆/不可逆」分类后再选速度。",
})
monthSeed := asOf.Year()*12 + int(asOf.Month()) + int(moon)/5
monthly := period("monthly", "本月运势", monthSeed, sun, moon, chart,
[]string{"感情", "事业", "财务", "健康节奏", "人际"},
[]string{
fmt.Sprintf("本月主题贴近「%s」:把热情落成节奏。", chart.Sun.Sign),
"感情上说清需要,比猜测更有效。",
"财务宜做一次月度复盘,砍掉低价值开销。",
"作息与运动选可持续的小剂量。",
"拓展一个人际弱连接,可能带来信息增益。",
})
yearSeed := asOf.Year() + int(sun) + int(chart.Rise.Lon)/10
yearly := period("yearly", "今年运势", yearSeed, sun, moon, chart,
[]string{"成长主线", "关系课题", "事业方向", "财富节奏", "身心"},
[]string{
fmt.Sprintf("今年宜强化太阳「%s」优势,并补月亮「%s」的安全感建设。", chart.Sun.Sign, chart.Moon.Sign),
"关系课题:边界与亲密并重,写成说明书。",
"事业上选择能看到里程碑的路径。",
"财富:先稳现金流,再谈进取配置。",
"身心:季度体检式复盘情绪与睡眠。",
})
life := lifetime(chart)
tr := transits(chart, asOf)
return Bundle{
Daily: daily, Weekly: weekly, Monthly: monthly, Yearly: yearly,
Lifetime: life, Transits: tr,
}
}
func lifetime(chart natal.Chart) Period {
sat := bodyLon(chart, "saturn")
seed := int(chart.Sun.Lon) + int(sat)/2 + int(chart.Moon.Lon)/3
score := 62 + seed%28
stages := []string{
fmt.Sprintf("成长主线贴近太阳「%s」:用可见行动定义自我。", chart.Sun.Sign),
fmt.Sprintf("情感底色来自月亮「%s」:安全感与表达节奏需要长期经营。", chart.Moon.Sign),
fmt.Sprintf("对外姿态偏上升「%s」:第一印象可以主动校准。", chart.Rise.Sign),
}
tip := stages[seed%len(stages)] + " 人生阶段不必一次做完,按十年课题拆解更稳。"
return Period{
Key: "lifetime", Title: "一生运势摘要", Score: score, Label: scoreLabel(score),
Dims: map[string]int{
"love": 50 + seed%40, "career": 52 + (seed*3)%40,
"money": 48 + (seed*5)%42, "mood": 55 + (seed*7)%35,
},
Tip: tip, Focus: "人生阶段",
Lucky: "长期复盘 · 边界清晰",
Caution: "避免把阶段标签当成宿命;可调整节奏与选择。",
}
}
func transits(chart natal.Chart, asOf time.Time) []Transit {
// Approximate "transit" sun/moon using same ephemeris at asOf noon CST.
loc := time.FixedZone("CST", 8*3600)
local := time.Date(asOf.Year(), asOf.Month(), asOf.Day(), 12, 0, 0, 0, loc)
tChart, err := natal.Compute(local, strPtr("12:00"), placePtr(chart.PlaceLabel))
if err != nil {
return nil
}
tSun, tMoon := tChart.Sun.Lon, tChart.Moon.Lon
out := []Transit{}
out = append(out, transitHit("transit_sun_natal_sun", "行运太阳×本命太阳", tSun, chart.Sun.Lon,
"适合主动推进与自我表达相关的事。",
"宜复盘目标,不急着扩张战线。",
"注意沟通语气,先对齐再行动。"))
out = append(out, transitHit("transit_moon_natal_moon", "行运月亮×本命月亮", tMoon, chart.Moon.Lon,
"情绪敏感日,给自己更多缓冲。",
"适合温柔连结与休息充电。",
"避免情绪化决策,先写下来再说。"))
out = append(out, transitHit("transit_sun_natal_moon", "行运太阳×本命月亮", tSun, chart.Moon.Lon,
"外在节奏触动内在需求,适合说清感受。",
"工作与情绪平衡日,留一点空白。",
"别硬撑社交,按电量选择场合。"))
return out
}
func transitHit(key, title string, a, b float64, conj, soft, hard string) Transit {
diff := natal.AngleDiff(a, b)
aspect, tip := "弱互动", "节奏平常,按计划推进即可。"
switch {
case near(diff, 0, 8):
aspect, tip = "合相", conj
case near(diff, 60, 6) || near(diff, 120, 7):
aspect, tip = "和谐相位", soft
case near(diff, 90, 7) || near(diff, 180, 8):
aspect, tip = "张力相位", hard
}
return Transit{Key: key, Title: title, Aspect: aspect, Tip: tip}
}
func near(diff, target, orb float64) bool {
return math.Abs(diff-target) <= orb
}
func bodyLon(c natal.Chart, key string) float64 {
for _, p := range c.Planets {
if p.Key == key {
return p.Lon
}
}
return 0
}
func strPtr(s string) *string { return &s }
func placePtr(s string) *string {
if s == "" || s == "默认(未填出生地)" {
return nil
}
return &s
}
func period(key, title string, seed int, sun, moon float64, chart natal.Chart, focuses, tips []string) Period {
score := 55 + (seed*7+int(sun)+int(moon))%41 // 5595
love := 50 + (seed*3+int(moon))%46
career := 50 + (seed*5+int(sun))%46
money := 48 + (seed*11+int(chart.Rise.Lon))%47
mood := 52 + (seed*13+int(moon)/2)%45
i := seed % len(tips)
return Period{
Key: key, Title: title, Score: score, Label: scoreLabel(score),
Dims: map[string]int{
"love": love, "career": career, "money": money, "mood": mood,
},
Tip: tips[i], Focus: focuses[i%len(focuses)],
Lucky: luckyFrom(seed),
Caution: cautionFrom(seed, chart),
}
}
func scoreLabel(s int) string {
switch {
case s >= 85:
return "大吉"
case s >= 75:
return "吉"
case s >= 65:
return "中平偏吉"
case s >= 55:
return "平稳"
default:
return "需谨慎"
}
}
func luckyFrom(seed int) string {
colors := []string{"红色", "金色", "蓝色", "绿色", "紫色", "白色"}
nums := []string{"3", "6", "7", "8", "9"}
return colors[seed%len(colors)] + " · 数字 " + nums[seed%len(nums)]
}
func cautionFrom(seed int, chart natal.Chart) string {
cautions := []string{
"避免冲动承诺,尤其财务相关。",
fmt.Sprintf("「%s」能量过强时易急躁,先深呼吸再回复。", chart.Sun.Sign),
"少开多线任务,完成比完美重要。",
"情绪波动时先写下来,再找人聊。",
}
return cautions[seed%len(cautions)]
}
func isoWeek(t time.Time) int {
_, w := t.ISOWeek()
return w
}
// AsMap for JSON embedding.
func (b Bundle) AsMap() map[string]any {
tr := make([]map[string]any, 0, len(b.Transits))
for _, t := range b.Transits {
tr = append(tr, map[string]any{
"key": t.Key, "title": t.Title, "aspect": t.Aspect, "tip": t.Tip,
})
}
return map[string]any{
"daily": periodMap(b.Daily), "weekly": periodMap(b.Weekly),
"monthly": periodMap(b.Monthly), "yearly": periodMap(b.Yearly),
"lifetime": periodMap(b.Lifetime), "transits": tr,
}
}
func periodMap(p Period) map[string]any {
return map[string]any{
"key": p.Key, "title": p.Title, "score": p.Score, "label": p.Label,
"dims": p.Dims, "tip": p.Tip, "lucky": p.Lucky, "caution": p.Caution, "focus": p.Focus,
}
}
@@ -0,0 +1,32 @@
package fortune
import (
"testing"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
func TestBuildPeriods(t *testing.T) {
chart, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
if err != nil {
t.Fatal(err)
}
asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC)
a := Build(chart, asOf)
b := Build(chart, asOf)
if a.Daily.Score != b.Daily.Score || a.Yearly.Tip != b.Yearly.Tip {
t.Fatal("not deterministic")
}
if a.Daily.Score < 50 || a.Weekly.Dims["love"] == 0 {
t.Fatalf("bad scores %+v", a.Daily)
}
m := a.AsMap()
if m["monthly"] == nil || m["lifetime"] == nil {
t.Fatal("missing monthly or lifetime")
}
tr, ok := m["transits"].([]map[string]any)
if !ok || len(tr) == 0 {
t.Fatalf("transits missing: %#v", m["transits"])
}
}
+90
View File
@@ -0,0 +1,90 @@
package natal
import (
"fmt"
"math"
"sort"
)
// Aspect is a major aspect between two chart points.
type Aspect struct {
A string `json:"a"`
B string `json:"b"`
ATitle string `json:"a_title"`
BTitle string `json:"b_title"`
Type string `json:"type"` // conjunction|sextile|square|trine|opposition
Angle float64 `json:"angle"`
Orb float64 `json:"orb"`
Label string `json:"label"`
}
var aspectDefs = []struct {
Type string
Angle float64
Orb float64
Label string
}{
{"conjunction", 0, 8, "合相"},
{"sextile", 60, 6, "六合"},
{"square", 90, 7, "刑相"},
{"trine", 120, 7, "拱相"},
{"opposition", 180, 8, "对冲"},
}
// majorKeys used for aspect table (外行星可选由前端过滤).
var majorKeys = []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"}
// Aspects computes major aspects among primary bodies.
func Aspects(chart Chart) []Aspect {
byKey := map[string]Body{}
for _, p := range chart.Planets {
byKey[p.Key] = p
}
var bodies []Body
for _, k := range majorKeys {
if b, ok := byKey[k]; ok {
bodies = append(bodies, b)
}
}
out := make([]Aspect, 0)
for i := 0; i < len(bodies); i++ {
for j := i + 1; j < len(bodies); j++ {
a, b := bodies[i], bodies[j]
diff := AngleDiff(a.Lon, b.Lon)
for _, def := range aspectDefs {
orb := math.Abs(diff - def.Angle)
if orb <= def.Orb {
out = append(out, Aspect{
A: a.Key, B: b.Key, ATitle: a.Title, BTitle: b.Title,
Type: def.Type, Angle: def.Angle, Orb: round1(orb),
Label: fmt.Sprintf("%s%s%s(容许%.1f°)", a.Title, def.Label, b.Title, orb),
})
break
}
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Orb != out[j].Orb {
return out[i].Orb < out[j].Orb
}
return out[i].Label < out[j].Label
})
return out
}
// AspectsAsMaps for JSON embedding.
func AspectsAsMaps(list []Aspect) []map[string]any {
out := make([]map[string]any, 0, len(list))
for _, a := range list {
out = append(out, map[string]any{
"a": a.A, "b": a.B, "a_title": a.ATitle, "b_title": a.BTitle,
"type": a.Type, "angle": a.Angle, "orb": a.Orb, "label": a.Label,
})
}
return out
}
func round1(x float64) float64 {
return math.Round(x*10) / 10
}
@@ -0,0 +1,28 @@
package natal
import (
"testing"
"time"
)
func TestAspectsDeterministic(t *testing.T) {
c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京"))
if err != nil {
t.Fatal(err)
}
a := Aspects(c)
b := Aspects(c)
if len(a) == 0 {
t.Fatal("expected some aspects")
}
if len(a) != len(b) || a[0].Label != b[0].Label {
t.Fatalf("not deterministic: %+v vs %+v", a[0], b[0])
}
for _, asp := range a {
if asp.Orb < 0 || asp.Orb > 8.1 {
t.Fatalf("orb out of range: %+v", asp)
}
}
}
func strPtr(s string) *string { return &s }
+148
View File
@@ -0,0 +1,148 @@
package natal
import (
"strings"
"unicode/utf8"
)
// City is a built-in birth place with WGS84 coordinates.
type City struct {
Name string
Lat float64
Lng float64
}
// Cities lists common CN cities for MVP geocode (no network).
var Cities = []City{
{"北京", 39.9042, 116.4074},
{"上海", 31.2304, 121.4737},
{"广州", 23.1291, 113.2644},
{"深圳", 22.5431, 114.0579},
{"杭州", 30.2741, 120.1551},
{"成都", 30.5728, 104.0668},
{"重庆", 29.5630, 106.5516},
{"武汉", 30.5928, 114.3055},
{"西安", 34.3416, 108.9398},
{"南京", 32.0603, 118.7969},
{"天津", 39.3434, 117.3616},
{"苏州", 31.2989, 120.5853},
{"长沙", 28.2282, 112.9388},
{"郑州", 34.7466, 113.6254},
{"青岛", 36.0671, 120.3826},
{"大连", 38.9140, 121.6147},
{"厦门", 24.4798, 118.0894},
{"福州", 26.0745, 119.2965},
{"昆明", 25.0389, 102.7183},
{"贵阳", 26.6470, 106.6302},
{"南宁", 22.8170, 108.3665},
{"海口", 20.0440, 110.1999},
{"哈尔滨", 45.8038, 126.5349},
{"长春", 43.8171, 125.3235},
{"沈阳", 41.8057, 123.4315},
{"石家庄", 38.0428, 114.5149},
{"太原", 37.8706, 112.5489},
{"济南", 36.6512, 117.1201},
{"合肥", 31.8206, 117.2272},
{"南昌", 28.6820, 115.8579},
{"兰州", 36.0611, 103.8343},
{"乌鲁木齐", 43.8256, 87.6168},
{"拉萨", 29.6520, 91.1721},
{"呼和浩特", 40.8414, 111.7519},
{"银川", 38.4872, 106.2309},
{"西宁", 36.6171, 101.7782},
{"香港", 22.3193, 114.1694},
{"澳门", 22.1987, 113.5439},
{"台北", 25.0330, 121.5654},
{"宁波", 29.8683, 121.5440},
{"无锡", 31.4912, 120.3119},
{"佛山", 23.0215, 113.1214},
{"东莞", 23.0207, 113.7518},
{"温州", 27.9943, 120.6994},
{"泉州", 24.8741, 118.6759},
{"珠海", 22.2710, 113.5767},
}
// DefaultCoords is used when place is missing (东八区中部近似).
const DefaultLat = 30.0
const DefaultLng = 114.0
// ResolvePlace returns lat/lng and whether a named city matched.
// Accepts plain city ("杭州") or 省市区 ("浙江省 杭州市 西湖区" / "北京市 市辖区 朝阳区").
func ResolvePlace(place string) (lat, lng float64, matched string, ok bool) {
place = strings.TrimSpace(place)
if place == "" {
return DefaultLat, DefaultLng, "", false
}
// exact city table hit
for _, c := range Cities {
if c.Name == place {
return c.Lat, c.Lng, c.Name, true
}
}
tokens := splitPlace(place)
// prefer matching city-level tokens (usually 2nd), then others
order := append([]string{}, tokens...)
if len(tokens) >= 2 {
order = append([]string{tokens[1]}, tokens[0])
if len(tokens) >= 3 {
order = append(order, tokens[2:]...)
}
}
for _, tok := range order {
if tok == "" || tok == "市辖区" || tok == "县" {
continue
}
key := normalizeAdmin(tok)
for _, c := range Cities {
if c.Name == key || strings.Contains(tok, c.Name) || strings.Contains(c.Name, key) {
return c.Lat, c.Lng, c.Name, true
}
}
}
// province-only fallback: use first token city if 直辖市
if len(tokens) > 0 {
key := normalizeAdmin(tokens[0])
for _, c := range Cities {
if c.Name == key {
return c.Lat, c.Lng, c.Name, true
}
}
}
return DefaultLat, DefaultLng, "", false
}
func splitPlace(s string) []string {
s = strings.ReplaceAll(s, "/", " ")
s = strings.ReplaceAll(s, "", " ")
s = strings.ReplaceAll(s, "", " ")
s = strings.ReplaceAll(s, ",", " ")
parts := strings.Fields(s)
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func normalizeAdmin(s string) string {
s = strings.TrimSpace(s)
suffixes := []string{"特别行政区", "维吾尔自治区", "壮族自治区", "回族自治区", "自治区", "省", "市", "地区", "盟"}
for _, suf := range suffixes {
if strings.HasSuffix(s, suf) && utf8.RuneCountInString(s) > utf8.RuneCountInString(suf) {
return strings.TrimSuffix(s, suf)
}
}
return s
}
// CityNames returns names for API/UI pickers.
func CityNames() []string {
out := make([]string, len(Cities))
for i, c := range Cities {
out[i] = c.Name
}
return out
}
+254
View File
@@ -0,0 +1,254 @@
// Package natal computes tropical whole-sign natal charts via Swiss Ephemeris.
package natal
import (
"fmt"
"math"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris"
)
// Body is a chart point.
type Body struct {
Key string `json:"key"`
Title string `json:"title"`
Lon float64 `json:"lon"`
SignKey string `json:"sign_key"`
Sign string `json:"sign"`
Degree float64 `json:"degree"` // 030 within sign
House int `json:"house"`
Element string `json:"element"`
Modality string `json:"modality"`
}
// House is a whole-sign house.
type House struct {
Num int `json:"num"`
Sign string `json:"sign"`
Key string `json:"sign_key"`
}
// Chart is a natal chart snapshot.
type Chart struct {
Sun, Moon, Rise Body
Planets []Body
Houses []House
Lat, Lng float64
PlaceLabel string
HasTime bool
HasPlace bool
Note string
// Instant is the UTC moment used for ephemeris (noon default when time missing).
Instant time.Time `json:"-"`
}
type signMeta struct {
Key, Label, Element, Modality string
}
var signs = []signMeta{
{"aries", "白羊", "火", "开创"},
{"taurus", "金牛", "土", "固定"},
{"gemini", "双子", "风", "变动"},
{"cancer", "巨蟹", "水", "开创"},
{"leo", "狮子", "火", "固定"},
{"virgo", "处女", "土", "变动"},
{"libra", "天秤", "风", "开创"},
{"scorpio", "天蝎", "水", "固定"},
{"sagittarius", "射手", "火", "变动"},
{"capricorn", "摩羯", "土", "开创"},
{"aquarius", "水瓶", "风", "固定"},
{"pisces", "双鱼", "水", "变动"},
}
var bodyTitles = map[string]string{
"sun": "太阳", "moon": "月亮", "rise": "上升",
"mercury": "水星", "venus": "金星", "mars": "火星",
"jupiter": "木星", "saturn": "土星", "uranus": "天王星",
"neptune": "海王星", "pluto": "冥王星",
}
// Compute builds a natal chart. birthTime is HH:MM local; place is city name.
func Compute(birthDate time.Time, birthTime *string, place *string) (Chart, error) {
lat, lng := DefaultLat, DefaultLng
placeLabel := "默认(未填出生地)"
hasPlace := false
if place != nil && *place != "" {
if la, ln, name, ok := ResolvePlace(*place); ok {
lat, lng, placeLabel, hasPlace = la, ln, name, true
} else {
placeLabel = *place + "(未匹配城市,用默认坐标)"
}
}
h, mi := 12, 0 // noon default when time missing
hasTime := false
if birthTime != nil && *birthTime != "" {
if hh, mm, ok := parseHM(*birthTime); ok {
h, mi, hasTime = hh, mm, true
}
}
// Treat civil time as UTC+8 for CN MVP (deterministic).
loc := time.FixedZone("CST", 8*3600)
local := time.Date(birthDate.Year(), birthDate.Month(), birthDate.Day(), h, mi, 0, 0, loc)
utc := local.UTC()
return ComputeAt(utc, lat, lng, placeLabel, hasTime, hasPlace)
}
// ComputeAt builds a chart for an exact UTC instant and coordinates.
func ComputeAt(utc time.Time, lat, lng float64, placeLabel string, hasTime, hasPlace bool) (Chart, error) {
utc = utc.UTC()
jd := ephemeris.JulianDayUT(utc)
lons, err := ephemeris.AllPlanetLons(jd)
if err != nil {
return Chart{}, fmt.Errorf("ephemeris: %w", err)
}
asc, err := ephemeris.Ascendant(jd, lat, lng)
if err != nil {
return Chart{}, fmt.Errorf("ephemeris asc: %w", err)
}
ch := ChartFromLons(lons, asc, lat, lng, placeLabel, hasTime, hasPlace)
ch.Instant = utc
ch.Note = fmt.Sprintf("热带黄道 · 整宫制 · 星历 %s。", ephemeris.Backend())
if !hasTime {
ch.Note += " 未填出生时,上升与宫位按正午估算。"
}
if !hasPlace {
ch.Note += " 填写出生地可提升上升准确度。"
}
return ch, nil
}
// ChartFromLons builds a whole-sign chart from body longitudes and ASC.
func ChartFromLons(lons map[string]float64, asc float64, lat, lng float64, placeLabel string, hasTime, hasPlace bool) Chart {
ascSignIdx := signIndex(asc)
mk := func(key string, lon float64) Body {
title := bodyTitles[key]
if title == "" {
title = key
}
idx := signIndex(lon)
s := signs[idx]
house := wholeSignHouse(ascSignIdx, idx)
return Body{
Key: key, Title: title, Lon: norm360(lon),
SignKey: s.Key, Sign: s.Label, Degree: math.Mod(norm360(lon), 30),
House: house, Element: s.Element, Modality: s.Modality,
}
}
sun := mk("sun", lons["sun"])
moon := mk("moon", lons["moon"])
rise := mk("rise", asc)
planets := []Body{
sun, moon, rise,
mk("mercury", lons["mercury"]),
mk("venus", lons["venus"]),
mk("mars", lons["mars"]),
mk("jupiter", lons["jupiter"]),
mk("saturn", lons["saturn"]),
mk("uranus", lons["uranus"]),
mk("neptune", lons["neptune"]),
mk("pluto", lons["pluto"]),
}
houses := make([]House, 12)
for i := 0; i < 12; i++ {
idx := (ascSignIdx + i) % 12
houses[i] = House{Num: i + 1, Sign: signs[idx].Label, Key: signs[idx].Key}
}
return Chart{
Sun: sun, Moon: moon, Rise: rise, Planets: planets, Houses: houses,
Lat: lat, Lng: lng, PlaceLabel: placeLabel, HasTime: hasTime, HasPlace: hasPlace,
}
}
// BodyLon returns longitude for a key, or 0.
func BodyLon(c Chart, key string) float64 {
for _, p := range c.Planets {
if p.Key == key {
return p.Lon
}
}
return 0
}
// SignMetaByKey returns label/element for a sign key.
func SignMetaByKey(key string) (label, element, modality string) {
for _, s := range signs {
if s.Key == key {
return s.Label, s.Element, s.Modality
}
}
return key, "", ""
}
// SignByIndex returns sign meta.
func SignByIndex(i int) (key, label, element, modality string) {
s := signs[((i%12)+12)%12]
return s.Key, s.Label, s.Element, s.Modality
}
func wholeSignHouse(ascIdx, bodyIdx int) int {
return ((bodyIdx-ascIdx)%12+12)%12 + 1
}
func signIndex(lon float64) int {
return int(math.Floor(norm360(lon)/30.0)) % 12
}
func parseHM(s string) (h, m int, ok bool) {
var hh, mm int
n, err := fmt.Sscanf(s, "%d:%d", &hh, &mm)
if err != nil || n < 1 || hh < 0 || hh > 23 {
return 0, 0, false
}
if n == 1 {
mm = 0
}
if mm < 0 || mm > 59 {
return 0, 0, false
}
return hh, mm, true
}
func norm360(x float64) float64 {
x = math.Mod(x, 360)
if x < 0 {
x += 360
}
return x
}
// AngleDiff returns smallest absolute ecliptic separation.
func AngleDiff(a, b float64) float64 {
d := math.Abs(norm360(a) - norm360(b))
if d > 180 {
d = 360 - d
}
return d
}
// MidpointLon returns the shorter-arc midpoint of two longitudes.
func MidpointLon(a, b float64) float64 {
a, b = norm360(a), norm360(b)
d := b - a
if d > 180 {
d -= 360
} else if d < -180 {
d += 360
}
return norm360(a + d/2)
}
// SignIndex is exported for overlay house mapping.
func SignIndex(lon float64) int { return signIndex(lon) }
// WholeSignHouse exported for overlay.
func WholeSignHouse(ascIdx, bodyIdx int) int { return wholeSignHouse(ascIdx, bodyIdx) }
@@ -0,0 +1,55 @@
package natal
import (
"testing"
"time"
)
func TestComputeDeterministic(t *testing.T) {
birth := time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC)
bt := "10:30"
place := "上海"
a, err := Compute(birth, &bt, &place)
if err != nil {
t.Fatal(err)
}
b, err := Compute(birth, &bt, &place)
if err != nil {
t.Fatal(err)
}
if a.Sun.Sign != b.Sun.Sign || a.Moon.Lon != b.Moon.Lon || a.Rise.Sign != b.Rise.Sign {
t.Fatal("not deterministic")
}
if len(a.Planets) < 8 || len(a.Houses) != 12 {
t.Fatalf("planets=%d houses=%d", len(a.Planets), len(a.Houses))
}
if a.Sun.Sign == "" {
t.Fatal("empty sun")
}
}
func TestResolvePlaceBeijing(t *testing.T) {
lat, lng, name, ok := ResolvePlace("北京")
if !ok || name != "北京" || lat < 39 || lng < 116 {
t.Fatalf("got %v %v %s %v", lat, lng, name, ok)
}
_, _, name2, ok2 := ResolvePlace("北京市 市辖区 朝阳区")
if !ok2 || name2 != "北京" {
t.Fatalf("pca resolve got %s %v", name2, ok2)
}
_, _, name3, ok3 := ResolvePlace("浙江省 杭州市 西湖区")
if !ok3 || name3 != "杭州" {
t.Fatalf("hangzhou resolve got %s %v", name3, ok3)
}
}
func TestSunSignMay(t *testing.T) {
c, err := Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
if err != nil {
t.Fatal(err)
}
// mid-May → Taurus
if c.Sun.SignKey != "taurus" {
t.Fatalf("want taurus got %s", c.Sun.SignKey)
}
}
+226
View File
@@ -0,0 +1,226 @@
package synastry
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/ephemeris"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// ChartPack holds all synastry chart modes for one pair.
type ChartPack struct {
Composite natal.Chart
Davison natal.Chart
MarksMe natal.Chart
MarksOther natal.Chart
Overlay OverlayChart
CompositeProgressed natal.Chart
DavisonProgressed natal.Chart
MarksProgressed natal.Chart
AsOf time.Time
}
// OverlayHouse is B's planet in A's whole-sign house.
type OverlayHouse struct {
PlanetKey string `json:"planet_key"`
Planet string `json:"planet"`
Sign string `json:"sign"`
House int `json:"house"`
HouseTip string `json:"house_tip"`
}
// OverlayChart is the pairing/overlay table.
type OverlayChart struct {
HousesA []natal.House `json:"houses_a"`
Entries []OverlayHouse `json:"entries"`
Tip string `json:"tip"`
}
// BuildCharts computes five main charts + three progressed variants.
func BuildCharts(a, b natal.Chart, asOf time.Time) (ChartPack, error) {
if asOf.IsZero() {
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
}
comp := CompositeChart(a, b)
dav, err := DavisonChart(a, b)
if err != nil {
return ChartPack{}, err
}
marksMe := MarksChart(dav, a)
marksOther := MarksChart(dav, b)
overlay := Overlay(a, b)
progA, err := ProgressChart(a, asOf)
if err != nil {
return ChartPack{}, err
}
progB, err := ProgressChart(b, asOf)
if err != nil {
return ChartPack{}, err
}
compProg := CompositeChart(progA, progB)
davProg, err := ProgressChart(dav, asOf)
if err != nil {
return ChartPack{}, err
}
marksProg := MarksChart(davProg, progA)
return ChartPack{
Composite: comp, Davison: dav,
MarksMe: marksMe, MarksOther: marksOther,
Overlay: overlay,
CompositeProgressed: compProg,
DavisonProgressed: davProg,
MarksProgressed: marksProg,
AsOf: asOf,
}, nil
}
// CompositeChart midpoints each body longitude (shortest arc); ASC mid of rises.
func CompositeChart(a, b natal.Chart) natal.Chart {
lons := map[string]float64{}
for _, k := range ephemeris.PlanetOrder {
lons[k] = natal.MidpointLon(natal.BodyLon(a, k), natal.BodyLon(b, k))
}
asc := natal.MidpointLon(a.Rise.Lon, b.Rise.Lon)
lat := (a.Lat + b.Lat) / 2
lng := midLng(a.Lng, b.Lng)
ch := natal.ChartFromLons(lons, asc, lat, lng, "组合盘", a.HasTime && b.HasTime, a.HasPlace || b.HasPlace)
ch.Instant = midTime(a.Instant, b.Instant)
ch.Note = "组合盘:双方行星黄经中点,看关系整体气质。"
return ch
}
// DavisonChart uses midpoint birth time + midpoint coordinates, then recomputes.
func DavisonChart(a, b natal.Chart) (natal.Chart, error) {
inst := midTime(a.Instant, b.Instant)
lat := (a.Lat + b.Lat) / 2
lng := midLng(a.Lng, b.Lng)
ch, err := natal.ComputeAt(inst, lat, lng, "时空盘", true, true)
if err != nil {
return natal.Chart{}, err
}
ch.Note = "时空盘:出生时刻与坐标中点再排盘,看长期现实走向。"
return ch, nil
}
// MarksChart midpoints each Davison body with a natal chart (我/TA 视角).
func MarksChart(davison, natalCh natal.Chart) natal.Chart {
lons := map[string]float64{}
for _, k := range ephemeris.PlanetOrder {
lons[k] = natal.MidpointLon(natal.BodyLon(davison, k), natal.BodyLon(natalCh, k))
}
asc := natal.MidpointLon(davison.Rise.Lon, natalCh.Rise.Lon)
ch := natal.ChartFromLons(lons, asc, natalCh.Lat, natalCh.Lng, "马克斯盘", true, true)
ch.Instant = midTime(davison.Instant, natalCh.Instant)
ch.Note = "马克斯盘:时空盘与本命中点,看关系中的内在态度。"
return ch
}
// Overlay maps B planets into A's whole-sign houses.
func Overlay(a, b natal.Chart) OverlayChart {
ascIdx := natal.SignIndex(a.Rise.Lon)
tips := houseTips()
entries := make([]OverlayHouse, 0, len(b.Planets))
for _, p := range b.Planets {
if p.Key == "rise" {
continue
}
h := natal.WholeSignHouse(ascIdx, natal.SignIndex(p.Lon))
tip := tips[h]
entries = append(entries, OverlayHouse{
PlanetKey: p.Key, Planet: p.Title, Sign: p.Sign, House: h, HouseTip: tip,
})
}
return OverlayChart{
HousesA: a.Houses,
Entries: entries,
Tip: "配对盘:对方行星落入我方整宫制宫位,提示生活领域互动。",
}
}
// ProgressChart applies secondary progression (1 day ≈ 1 year) from chart.Instant to asOf.
func ProgressChart(ch natal.Chart, asOf time.Time) (natal.Chart, error) {
if ch.Instant.IsZero() {
return ch, nil
}
years := asOf.UTC().Sub(ch.Instant).Hours() / 24.0 / 365.24219
if years < 0 {
years = 0
}
// Add whole days to avoid time.Duration overflow/precision issues for large ages.
days := int(years + 0.5)
progUTC := ch.Instant.AddDate(0, 0, days)
out, err := natal.ComputeAt(progUTC, ch.Lat, ch.Lng, ch.PlaceLabel+"·次限", ch.HasTime, ch.HasPlace)
if err != nil {
return natal.Chart{}, err
}
out.Note = fmt.Sprintf("次限推运至 %s(约 %.1f 年)。", asOf.Format("2006-01-02"), years)
return out, nil
}
func midTime(a, b time.Time) time.Time {
if a.IsZero() && b.IsZero() {
return time.Time{}
}
if a.IsZero() {
return b
}
if b.IsZero() {
return a
}
return a.Add(b.Sub(a) / 2)
}
func midLng(a, b float64) float64 {
// Geographic mean (CN MVP; both births typically East Asia).
return (a + b) / 2
}
func houseTips() map[int]string {
return map[int]string{
1: "自我与第一印象", 2: "资源与安全感", 3: "沟通与日常", 4: "家庭与根基",
5: "恋爱表达与创造", 6: "协作与健康节奏", 7: "一对一关系", 8: "深度与共享",
9: "视野与信念", 10: "事业与对外形象", 11: "社群与愿景", 12: "内在与疗愈",
}
}
func chartSummaryOut(c natal.Chart, tip string) map[string]any {
asp := natal.Aspects(c)
aspMaps := natal.AspectsAsMaps(asp)
previewN := 4
if len(aspMaps) < previewN {
previewN = len(aspMaps)
}
t := tip
if t == "" {
t = c.Note
}
return map[string]any{
"asc_lon": c.Rise.Lon,
"planets": bodiesOut(c),
"houses": housesOut(c),
"note": c.Note,
"place": c.PlaceLabel,
"tip": t,
"aspects_preview": aspMaps[:previewN],
"sun": c.Sun.Sign,
"moon": c.Moon.Sign,
"rise": c.Rise.Sign,
}
}
func overlayOut(o OverlayChart) map[string]any {
entries := make([]map[string]any, 0, len(o.Entries))
for _, e := range o.Entries {
entries = append(entries, map[string]any{
"planet_key": e.PlanetKey, "planet": e.Planet, "sign": e.Sign,
"house": e.House, "house_tip": e.HouseTip,
})
}
return map[string]any{
"tip": o.Tip, "entries": entries, "houses_a": housesOut(natal.Chart{Houses: o.HousesA}),
}
}
+275
View File
@@ -0,0 +1,275 @@
package synastry
import (
"fmt"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Report is free summary + gated detail for a synastry GrowthReport.
type Report struct {
Summary map[string]any
Detail map[string]any
}
// BuildReport builds multi-chart synastry content (五主盘 + 三推运).
func BuildReport(a, b natal.Chart, aName, bName string, asOf time.Time) (Report, error) {
if aName == "" {
aName = "我"
}
if bName == "" {
bName = "TA"
}
if asOf.IsZero() {
asOf = time.Now().In(time.FixedZone("CST", 8*3600))
}
idx := Compute(a, b, aName, bName)
cross := CrossAspects(a, b)
crossMaps := natal.AspectsAsMaps(cross)
previewN := 4
if len(crossMaps) < previewN {
previewN = len(crossMaps)
}
pack, err := BuildCharts(a, b, asOf)
if err != nil {
return Report{}, err
}
asOfStr := asOf.Format("2006-01-02")
chartsSummary := map[string]any{
"compare": map[string]any{
"tip": "比较盘:双方叠合,看互动与吸引。",
"chart_a": map[string]any{
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
"sun": a.Sun.Sign, "moon": a.Moon.Sign, "rise": a.Rise.Sign,
},
"chart_b": map[string]any{
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
"sun": b.Sun.Sign, "moon": b.Moon.Sign, "rise": b.Rise.Sign,
},
"aspects_preview": crossMaps[:previewN],
},
"composite": chartSummaryOut(pack.Composite, "组合盘:关系整体气质。"),
"davison": chartSummaryOut(pack.Davison, "时空盘:长期现实走向。"),
"marks_me": chartSummaryOut(pack.MarksMe, "马克斯盘·我:我对这段关系的内在态度。"),
"marks_other": chartSummaryOut(pack.MarksOther, "马克斯盘·TA:TA对这段关系的内在态度。"),
"overlay": overlayOut(pack.Overlay),
"composite_progressed": chartSummaryOut(pack.CompositeProgressed, "组合次限:关系当下成长态。"),
"davison_progressed": chartSummaryOut(pack.DavisonProgressed, "时空次限:现实课题的阶段性。"),
"marks_progressed": chartSummaryOut(pack.MarksProgressed, "马盘推运:我对关系的阶段心态。"),
}
summary := map[string]any{
"title": "合盘·基础",
"headline": idx.Summary,
"one_liner": fmt.Sprintf("%s与%s:五盘合观互动、气质与现实节奏。", aName, bName),
"overview": fmt.Sprintf("%s(日%s月%s升%s)× %s(日%s月%s升%s)。", aName, a.Sun.Sign, a.Moon.Sign, a.Rise.Sign, bName, b.Sun.Sign, b.Moon.Sign, b.Rise.Sign),
"me_name": aName,
"other_name": bName,
"love_index": idx.Love,
"friend_index": idx.Friend,
"marriage_index": idx.Marriage,
"match_indices": idx.AsMap(),
"love_note": idx.LoveNote,
"friend_note": idx.FriendNote,
"marriage_note": idx.MarriageNote,
"as_of": asOfStr,
// backward-compat top-level compare charts
"chart_a": map[string]any{
"asc_lon": a.Rise.Lon, "planets": bodiesOut(a),
"houses": housesOut(a), "note": a.Note, "place": a.PlaceLabel,
},
"chart_b": map[string]any{
"asc_lon": b.Rise.Lon, "planets": bodiesOut(b),
"houses": housesOut(b), "note": b.Note, "place": b.PlaceLabel,
},
"aspects_preview": crossMaps[:previewN],
"charts": chartsSummary,
"keywords": []string{"合盘", "比较盘", "组合盘", "时空盘", fmt.Sprintf("恋爱%d", idx.Love)},
"strengths_preview": []string{
fmt.Sprintf("恋爱指数 %d", idx.Love),
fmt.Sprintf("友情指数 %d", idx.Friend),
fmt.Sprintf("婚姻指数 %d", idx.Marriage),
},
"blind_spots_preview": []string{"完整相位、推运深文案与落宫解读见深度版。"},
}
bullets := make([]string, 0, min(10, len(cross)))
for i, c := range cross {
if i >= 10 {
break
}
bullets = append(bullets, c.Label)
}
compAsp := natal.AspectsAsMaps(natal.Aspects(pack.Composite))
davAsp := natal.AspectsAsMaps(natal.Aspects(pack.Davison))
progAsp := natal.AspectsAsMaps(natal.Aspects(pack.CompositeProgressed))
detail := map[string]any{
"title": "合盘·完整分析",
"as_of": asOfStr,
"aspects": crossMaps,
"charts": map[string]any{
"compare": map[string]any{"aspects": crossMaps},
"composite": map[string]any{"aspects": compAsp, "planets": bodiesOut(pack.Composite)},
"davison": map[string]any{"aspects": davAsp, "planets": bodiesOut(pack.Davison)},
"marks_me": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksMe)), "planets": bodiesOut(pack.MarksMe)},
"marks_other": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksOther)), "planets": bodiesOut(pack.MarksOther)},
"overlay": overlayOut(pack.Overlay),
"composite_progressed": map[string]any{"aspects": progAsp, "planets": bodiesOut(pack.CompositeProgressed), "note": pack.CompositeProgressed.Note},
"davison_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.DavisonProgressed)), "planets": bodiesOut(pack.DavisonProgressed), "note": pack.DavisonProgressed.Note},
"marks_progressed": map[string]any{"aspects": natal.AspectsAsMaps(natal.Aspects(pack.MarksProgressed)), "planets": bodiesOut(pack.MarksProgressed), "note": pack.MarksProgressed.Note},
},
"sections": []map[string]any{
{"title": "恋爱互动", "body": idx.LoveNote, "bullets": []string{"说清需要比猜测更有效", "张力相位日宜慢半拍回应"}},
{"title": "友情节奏", "body": idx.FriendNote, "bullets": []string{"共同兴趣维系轻松感", "尊重彼此独处需求"}},
{"title": "长期相处", "body": idx.MarriageNote, "bullets": []string{"共同节奏与边界同等重要", "把期待写成说明书"}},
{"title": "跨盘相位", "body": "比较盘相位描述双方能量如何彼此激活或拉扯。", "bullets": bullets},
{"title": "组合与时空", "body": "组合盘看关系气质,时空盘看共同现实课题;次限呈现阶段性成长。", "bullets": []string{
fmt.Sprintf("组合盘日%s月%s升%s", pack.Composite.Sun.Sign, pack.Composite.Moon.Sign, pack.Composite.Rise.Sign),
fmt.Sprintf("时空盘日%s月%s升%s", pack.Davison.Sun.Sign, pack.Davison.Moon.Sign, pack.Davison.Rise.Sign),
fmt.Sprintf("推运日期 %s", asOfStr),
}},
{"title": "配对落宫", "body": pack.Overlay.Tip, "bullets": overlayBullets(pack.Overlay)},
},
"growth_plan": []map[string]any{
{"phase": "本周", "focus": "约一次轻松同步:各说一件欣赏与一件需要。"},
{"phase": "本月", "focus": "为高频摩擦点写一条可执行约定。"},
{"phase": "长期", "focus": "每季度复盘三指数变化与相处舒适度。"},
},
"conversation_scripts": []string{
"我想听听你怎么看我们的节奏,不急着定结论。",
"我需要一点空间整理,不是拒绝连接。",
"我们共同目标是……,这一步只做一件事。",
},
"behavior_pattern": idx.LoveNote,
"relation_style": idx.FriendNote,
"growth_direction": idx.MarriageNote,
}
return Report{Summary: summary, Detail: detail}, nil
}
func overlayBullets(o OverlayChart) []string {
out := make([]string, 0, 6)
for i, e := range o.Entries {
if i >= 6 {
break
}
if e.PlanetKey == "sun" || e.PlanetKey == "moon" || e.PlanetKey == "venus" || e.PlanetKey == "mars" {
out = append(out, fmt.Sprintf("%s在我方%d宫(%s", e.Planet, e.House, e.HouseTip))
}
}
if len(out) == 0 && len(o.Entries) > 0 {
e := o.Entries[0]
out = append(out, fmt.Sprintf("%s在我方%d宫", e.Planet, e.House))
}
return out
}
// CrossAspects finds aspects between chart A bodies and chart B bodies.
func CrossAspects(a, b natal.Chart) []natal.Aspect {
keys := []string{"sun", "moon", "rise", "mercury", "venus", "mars", "jupiter", "saturn"}
byA := map[string]natal.Body{}
byB := map[string]natal.Body{}
for _, p := range a.Planets {
byA[p.Key] = p
}
for _, p := range b.Planets {
byB[p.Key] = p
}
defs := []struct {
Type string
Angle float64
Orb float64
Label string
}{
{"conjunction", 0, 8, "合相"},
{"sextile", 60, 6, "六合"},
{"square", 90, 7, "刑相"},
{"trine", 120, 7, "拱相"},
{"opposition", 180, 8, "对冲"},
}
out := make([]natal.Aspect, 0)
for _, ka := range keys {
ba, oka := byA[ka]
if !oka {
continue
}
for _, kb := range keys {
bb, okb := byB[kb]
if !okb {
continue
}
diff := natal.AngleDiff(ba.Lon, bb.Lon)
for _, def := range defs {
orb := absF(diff - def.Angle)
if orb <= def.Orb {
out = append(out, natal.Aspect{
A: "a:" + ba.Key, B: "b:" + bb.Key,
ATitle: aNameTitle(ba.Title), BTitle: bNameTitle(bb.Title),
Type: def.Type, Angle: def.Angle, Orb: round1(orb),
Label: fmt.Sprintf("我方%s%s对方%s(容许%.1f°)", ba.Title, def.Label, bb.Title, orb),
})
break
}
}
}
}
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Orb < out[i].Orb {
out[i], out[j] = out[j], out[i]
}
}
}
if len(out) > 24 {
out = out[:24]
}
return out
}
func aNameTitle(t string) string { return "我·" + t }
func bNameTitle(t string) string { return "TA·" + t }
func bodiesOut(c natal.Chart) []map[string]any {
out := make([]map[string]any, 0, len(c.Planets))
for _, p := range c.Planets {
out = append(out, map[string]any{
"key": p.Key, "title": p.Title, "sign": p.Sign, "sign_key": p.SignKey,
"degree": fmt.Sprintf("%.1f°", p.Degree), "house": p.House,
"lon": p.Lon, "element": p.Element, "modality": p.Modality,
})
}
return out
}
func housesOut(c natal.Chart) []map[string]any {
out := make([]map[string]any, 0, len(c.Houses))
for _, h := range c.Houses {
out = append(out, map[string]any{"num": h.Num, "sign": h.Sign, "sign_key": h.Key})
}
return out
}
func absF(x float64) float64 {
if x < 0 {
return -x
}
return x
}
func round1(x float64) float64 {
return float64(int(x*10+0.5)) / 10
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,65 @@
package synastry
import (
"testing"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
func TestBuildReport(t *testing.T) {
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("12:00"), strPtr("北京"))
if err != nil {
t.Fatal(err)
}
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("上海"))
if err != nil {
t.Fatal(err)
}
asOf := time.Date(2026, 8, 2, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
rep, err := BuildReport(a, b, "我", "TA", asOf)
if err != nil {
t.Fatal(err)
}
if rep.Summary["love_index"] == nil || rep.Summary["chart_a"] == nil {
t.Fatalf("summary incomplete: %#v", rep.Summary)
}
charts, ok := rep.Summary["charts"].(map[string]any)
if !ok {
t.Fatal("charts missing")
}
for _, k := range []string{"compare", "composite", "davison", "marks_me", "marks_other", "overlay",
"composite_progressed", "davison_progressed", "marks_progressed"} {
if charts[k] == nil {
t.Fatalf("missing chart key %s", k)
}
}
if rep.Summary["as_of"] != "2026-08-02" {
t.Fatalf("as_of=%v", rep.Summary["as_of"])
}
if rep.Detail["aspects"] == nil {
t.Fatal("detail aspects missing")
}
cross := CrossAspects(a, b)
if len(cross) == 0 {
t.Fatal("expected cross aspects")
}
}
func TestCompositeDeterministic(t *testing.T) {
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), strPtr("10:30"), strPtr("上海"))
if err != nil {
t.Fatal(err)
}
b, err := natal.Compute(time.Date(1992, 8, 20, 0, 0, 0, 0, time.UTC), strPtr("08:00"), strPtr("北京"))
if err != nil {
t.Fatal(err)
}
c1 := CompositeChart(a, b)
c2 := CompositeChart(a, b)
if c1.Sun.Lon != c2.Sun.Lon || c1.Rise.Sign != c2.Rise.Sign {
t.Fatal("composite not deterministic")
}
}
func strPtr(s string) *string { return &s }
+133
View File
@@ -0,0 +1,133 @@
// Package synastry computes love/friend/marriage match indices from two natal charts.
package synastry
import (
"fmt"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
// Indices are 0100 match scores.
type Indices struct {
Love int `json:"love_index"`
Friend int `json:"friend_index"`
Marriage int `json:"marriage_index"`
LoveNote string `json:"love_note"`
FriendNote string `json:"friend_note"`
MarriageNote string `json:"marriage_note"`
Summary string `json:"summary"`
}
// Compute returns match indices for charts A and B.
func Compute(a, b natal.Chart, aName, bName string) Indices {
if aName == "" {
aName = "我"
}
if bName == "" {
bName = "TA"
}
sunDiff := natal.AngleDiff(a.Sun.Lon, b.Sun.Lon)
moonDiff := natal.AngleDiff(a.Moon.Lon, b.Moon.Lon)
venA := bodyLon(a, "venus")
venB := bodyLon(b, "venus")
marsA := bodyLon(a, "mars")
marsB := bodyLon(b, "mars")
venDiff := natal.AngleDiff(venA, venB)
marsVen := natal.AngleDiff(marsA, venB)
venMars := natal.AngleDiff(venA, marsB)
// Closer aspects (0/60/120 soft; 90/180 hard but charged) → higher score blended
love := clamp(58+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+aspectBoost(marsVen)/2+aspectBoost(venMars)/2, 42, 98)
friend := clamp(60+aspectBoost(sunDiff)+aspectBoost(moonDiff)*3/2+elemBoost(a.Sun, b.Sun), 45, 97)
marriage := clamp(55+aspectBoost(sunDiff)+aspectBoost(moonDiff)+aspectBoost(venDiff)+houseBoost(a, b), 40, 96)
return Indices{
Love: love, Friend: friend, Marriage: marriage,
LoveNote: fmt.Sprintf("%s与%s恋爱指数 %d:太阳相距约%.0f°,金星互动影响吸引力。", aName, bName, love, sunDiff),
FriendNote: fmt.Sprintf("友情指数 %d:月亮与太阳元素是否合拍,决定轻松感。", friend),
MarriageNote: fmt.Sprintf("婚姻/长期指数 %d:看重稳定与共同节奏,而非一时火花。", marriage),
Summary: fmt.Sprintf("%s × %s:恋爱 %d · 友情 %d · 婚姻 %d", aName, bName, love, friend, marriage),
}
}
func bodyLon(c natal.Chart, key string) float64 {
for _, p := range c.Planets {
if p.Key == key {
return p.Lon
}
}
return 0
}
func aspectBoost(diff float64) int {
// reward conjunction, sextile, trine; mild for square/opposition
targets := []float64{0, 60, 90, 120, 180}
best := 180.0
for _, t := range targets {
d := abs(diff - t)
if d < best {
best = d
}
}
switch {
case best <= 8:
if near(diff, 90) || near(diff, 180) {
return 8
}
return 14
case best <= 12:
return 8
default:
return int(6 - best/30)
}
}
func near(diff, target float64) bool {
return abs(diff-target) <= 10
}
func elemBoost(a, b natal.Body) int {
if a.Element == b.Element {
return 6
}
// fire-air / earth-water traditionally supportive
pair := a.Element + b.Element
if pair == "火风" || pair == "风火" || pair == "土水" || pair == "水土" {
return 4
}
return 0
}
func houseBoost(a, b natal.Chart) int {
// same rising modality → slight boost
if a.Rise.Modality == b.Rise.Modality {
return 4
}
return 0
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
// AsMap for JSON.
func (i Indices) AsMap() map[string]any {
return map[string]any{
"love_index": i.Love, "friend_index": i.Friend, "marriage_index": i.Marriage,
"love_note": i.LoveNote, "friend_note": i.FriendNote, "marriage_note": i.MarriageNote,
"summary": i.Summary,
}
}
@@ -0,0 +1,26 @@
package synastry
import (
"testing"
"time"
"github.com/yuxingu/digital-psychology/apps/api/internal/star/natal"
)
func TestComputeIndices(t *testing.T) {
a, err := natal.Compute(time.Date(1990, 5, 12, 0, 0, 0, 0, time.UTC), nil, nil)
if err != nil {
t.Fatal(err)
}
b, err := natal.Compute(time.Date(1992, 8, 1, 0, 0, 0, 0, time.UTC), nil, nil)
if err != nil {
t.Fatal(err)
}
idx := Compute(a, b, "我", "TA")
if idx.Love < 40 || idx.Friend < 40 || idx.Marriage < 40 {
t.Fatalf("scores too low: %+v", idx)
}
if idx.Summary == "" {
t.Fatal("empty summary")
}
}
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ask_messages;
DROP TABLE IF EXISTS ask_threads;
+23
View File
@@ -0,0 +1,23 @@
-- Ask threads & messages (AI 成长助手)
CREATE TABLE IF NOT EXISTS ask_threads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
profile_id uuid NOT NULL REFERENCES profiles(id),
scene 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_ask_threads_user_id ON ask_threads(user_id);
CREATE INDEX IF NOT EXISTS idx_ask_threads_profile_id ON ask_threads(profile_id);
CREATE TABLE IF NOT EXISTS ask_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id uuid NOT NULL REFERENCES ask_threads(id),
role varchar(16) NOT NULL CHECK (role IN ('user', 'assistant')),
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);
CREATE INDEX IF NOT EXISTS idx_ask_messages_thread_id ON ask_messages(thread_id);
@@ -0,0 +1,2 @@
DELETE FROM scale_questions WHERE scale_id = '22222222-2222-2222-2222-222222222222';
DELETE FROM scales WHERE id = '22222222-2222-2222-2222-222222222222';
@@ -0,0 +1,30 @@
-- Second 探索测试: emotion pattern
INSERT INTO scales (id, slug, title, description, status)
VALUES (
'22222222-2222-2222-2222-222222222222',
'emotion-pattern',
'情感模式探索',
'了解你在情绪起伏时的调节偏好,生成探索结果标签。',
'published'
) ON CONFLICT (slug) DO NOTHING;
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222222', 1,
'{"prompt":"情绪上来时,你通常先做什么?","options":[{"key":"A","text":"说出感受,让对方知道"},{"key":"B","text":"先让自己平静下来"},{"key":"C","text":"去做一件能消耗精力的事"}]}'
WHERE NOT EXISTS (
SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222222' AND sort=1 AND deleted_at IS NULL
);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222222', 2,
'{"prompt":"压力很大的一周,什么最能帮到你?","options":[{"key":"A","text":"有人认真听你说完"},{"key":"B","text":"稳定的作息与熟悉的节奏"},{"key":"C","text":"把事情拆开并行动起来"}]}'
WHERE NOT EXISTS (
SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222222' AND sort=2 AND deleted_at IS NULL
);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222222', 3,
'{"prompt":"和亲近的人冷战时,你更倾向?","options":[{"key":"A","text":"主动表达委屈与需要"},{"key":"B","text":"先降温,等双方都稳住"},{"key":"C","text":"用具体行动修复关系"}]}'
WHERE NOT EXISTS (
SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222222' AND sort=3 AND deleted_at IS NULL
);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS image_card_quotas;
DROP TABLE IF EXISTS moods;
@@ -0,0 +1,21 @@
-- Companion moods + (optional) image-card daily quota counter
CREATE TABLE IF NOT EXISTS moods (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
day date NOT NULL,
score int NULL CHECK (score IS NULL OR (score >= 1 AND score <= 5)),
note text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
UNIQUE (user_id, day)
);
CREATE INDEX IF NOT EXISTS idx_moods_user_id ON moods(user_id);
CREATE TABLE IF NOT EXISTS image_card_quotas (
user_id uuid NOT NULL REFERENCES users(id),
day date NOT NULL,
used int NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day)
);
@@ -0,0 +1,6 @@
DROP TABLE IF EXISTS favorites;
DROP TABLE IF EXISTS growth_checkins;
DROP TABLE IF EXISTS growth_plans;
-- scale seeds left in place (safe); delete by id if rolling back hard:
-- DELETE FROM scale_questions WHERE scale_id LIKE '22222222%';
-- DELETE FROM scales WHERE id LIKE '22222222%';
@@ -0,0 +1,115 @@
-- Growth plans + favorites; scale matrix seeds (≥8 official scales)
CREATE TABLE IF NOT EXISTS growth_plans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
title text NOT NULL,
focus text NOT NULL DEFAULT '',
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 INDEX IF NOT EXISTS idx_growth_plans_user_id ON growth_plans(user_id);
CREATE TABLE IF NOT EXISTS growth_checkins (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id uuid NOT NULL REFERENCES growth_plans(id),
user_id uuid NOT NULL REFERENCES users(id),
day date NOT NULL,
note text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (plan_id, day)
);
CREATE INDEX IF NOT EXISTS idx_growth_checkins_user_id ON growth_checkins(user_id);
CREATE TABLE IF NOT EXISTS favorites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id),
kind varchar(32) NOT NULL,
ref_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, kind, ref_id)
);
-- Helper: insert scale if missing (ids fixed for idempotency)
INSERT INTO scales (id, slug, title, description, status) VALUES
('22222222-2222-2222-2222-222222222201', 'mbti-lite', '人格类型探索', '了解能量来源与决策偏好的轻量探索。', 'published'),
('22222222-2222-2222-2222-222222222202', 'enneagram-lite', '动机模式探索', '觉察内在动机与压力反应。', 'published'),
('22222222-2222-2222-2222-222222222203', 'bigfive-lite', '性格五维探索', '稳定特质的自我速览。', 'published'),
('22222222-2222-2222-2222-222222222204', 'love-style', '亲密互动探索', '亲密关系中的表达与边界偏好。', 'published'),
('22222222-2222-2222-2222-222222222205', 'eq-lite', '情绪觉察探索', '识别与调节情绪的习惯。', 'published'),
('22222222-2222-2222-2222-222222222206', 'stress-index', '压力负荷探索', '近期压力与恢复方式。', 'published'),
('22222222-2222-2222-2222-222222222207', 'career-interest', '职业兴趣探索', '工作动力与环境偏好。', 'published')
ON CONFLICT (slug) DO NOTHING;
-- Questions: 3 each (MVP); scoring handled by existing engine via option keys
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222201', 1, '{"prompt":"周末充电,你更想?","options":[{"key":"E","text":"和朋友见面聊天"},{"key":"I","text":"独处安静恢复"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222201' AND sort=1);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222201', 2, '{"prompt":"做决定时你更看重?","options":[{"key":"T","text":"逻辑与公平"},{"key":"F","text":"感受与关系"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222201' AND sort=2);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222201', 3, '{"prompt":"面对计划,你更倾向?","options":[{"key":"J","text":"提前安排清楚"},{"key":"P","text":"保持弹性随机"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222201' AND sort=3);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222202', 1, '{"prompt":"压力大时你最先?","options":[{"key":"A","text":"想把事情做对"},{"key":"B","text":"想被理解和支持"},{"key":"C","text":"想先躲开冲突"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222202' AND sort=1);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222202', 2, '{"prompt":"你最在意别人怎么看你?","options":[{"key":"A","text":"是否可靠靠谱"},{"key":"B","text":"是否温暖有情"},{"key":"C","text":"是否独特有想法"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222202' AND sort=2);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222202', 3, '{"prompt":"成长路上你常提醒自己?","options":[{"key":"A","text":"再努力一点就好"},{"key":"B","text":"别忽略自己的感受"},{"key":"C","text":"保持真实就好"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222202' AND sort=3);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222203', 1, '{"prompt":"新环境里你通常?","options":[{"key":"O","text":"好奇尝试新事物"},{"key":"C","text":"先观察再行动"},{"key":"E","text":"主动认识新朋友"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222203' AND sort=1);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222203', 2, '{"prompt":"做事风格更接近?","options":[{"key":"C","text":"有条理、按计划"},{"key":"A","text":"随情境灵活调整"},{"key":"N","text":"容易想很多细节"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222203' AND sort=2);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222203', 3, '{"prompt":"相处中你更常?","options":[{"key":"A","text":"温和配合"},{"key":"E","text":"直接表达立场"},{"key":"O","text":"提出新点子"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222203' AND sort=3);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222204', 1, '{"prompt":"亲密关系里你更需要?","options":[{"key":"A","text":"稳定陪伴与承诺"},{"key":"B","text":"深度交流与共鸣"},{"key":"C","text":"各自空间与自由"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222204' AND sort=1);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222204', 2, '{"prompt":"发生误解时你会?","options":[{"key":"A","text":"尽快说开"},{"key":"B","text":"先冷静再谈"},{"key":"C","text":"用行动表达关心"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222204' AND sort=2);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222204', 3, '{"prompt":"表达喜欢时你更习惯?","options":[{"key":"A","text":"说出来"},{"key":"B","text":"做具体的事"},{"key":"C","text":"制造共同回忆"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222204' AND sort=3);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222205', 1, '{"prompt":"情绪上来时你最先?","options":[{"key":"A","text":"能叫出它的名字"},{"key":"B","text":"想马上做点什么压下去"},{"key":"C","text":"不太确定自己怎么了"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222205' AND sort=1);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222205', 2, '{"prompt":"朋友难过时你更会?","options":[{"key":"A","text":"陪着听"},{"key":"B","text":"给建议"},{"key":"C","text":"一起转移注意"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222205' AND sort=2);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222205', 3, '{"prompt":"调节自己时最有效的是?","options":[{"key":"A","text":"散步或运动"},{"key":"B","text":"写下来或说出来"},{"key":"C","text":"睡一觉再说"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222205' AND sort=3);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222206', 1, '{"prompt":"最近一周睡眠?","options":[{"key":"A","text":"大体规律"},{"key":"B","text":"时好时坏"},{"key":"C","text":"经常不足"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222206' AND sort=1);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222206', 2, '{"prompt":"任务堆积时你?","options":[{"key":"A","text":"列清单逐个清"},{"key":"B","text":"先做紧迫的"},{"key":"C","text":"容易拖延焦虑"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222206' AND sort=2);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222206', 3, '{"prompt":"恢复精力最依赖?","options":[{"key":"A","text":"运动与日照"},{"key":"B","text":"社交倾诉"},{"key":"C","text":"独处安静"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222206' AND sort=3);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222207', 1, '{"prompt":"理想工作更像?","options":[{"key":"A","text":"创造与表达"},{"key":"B","text":"帮助与支持他人"},{"key":"C","text":"分析与解决问题"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222207' AND sort=1);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222207', 2, '{"prompt":"团队里你自然扮演?","options":[{"key":"A","text":"提出方向的人"},{"key":"B","text":"协调氛围的人"},{"key":"C","text":"把细节落地的人"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222207' AND sort=2);
INSERT INTO scale_questions (scale_id, sort, body)
SELECT '22222222-2222-2222-2222-222222222207', 3, '{"prompt":"职业选择你最在意?","options":[{"key":"A","text":"意义感"},{"key":"B","text":"成长空间"},{"key":"C","text":"稳定与平衡"}]}'
WHERE NOT EXISTS (SELECT 1 FROM scale_questions WHERE scale_id='22222222-2222-2222-2222-222222222207' AND sort=3);
@@ -0,0 +1,2 @@
-- no-op: birth_place may pre-exist from 000001
SELECT 1;
@@ -0,0 +1,2 @@
-- birth_place already on profiles from init; ensure column exists for older DBs.
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS birth_place varchar(128) NULL;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS synastry_invites;
DROP INDEX IF EXISTS idx_profiles_geo_visible;
ALTER TABLE profiles DROP COLUMN IF EXISTS geo_visible;
ALTER TABLE profiles DROP COLUMN IF EXISTS geo_lng;
ALTER TABLE profiles DROP COLUMN IF EXISTS geo_lat;
@@ -0,0 +1,22 @@
-- Synastry social: profile geo + invite tokens
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS geo_lat double precision NULL;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS geo_lng double precision NULL;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS geo_visible boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS synastry_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token varchar(64) NOT NULL UNIQUE,
host_user_id uuid NOT NULL REFERENCES users(id),
host_profile_id uuid NOT NULL REFERENCES profiles(id),
expires_at timestamptz NOT NULL,
guest_user_id uuid NULL REFERENCES users(id),
guest_profile_id uuid NULL REFERENCES profiles(id),
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_synastry_invites_token ON synastry_invites(token);
CREATE INDEX IF NOT EXISTS idx_synastry_invites_host ON synastry_invites(host_user_id);
CREATE INDEX IF NOT EXISTS idx_profiles_geo_visible ON profiles(geo_visible) WHERE geo_visible = true AND deleted_at IS NULL;