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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) != ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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: "黑",
|
||||
},
|
||||
}
|
||||
@@ -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 62–92 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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "爱心泛滥到溢出,聚焦一件最想改变的事会更有力量",
|
||||
}
|
||||
@@ -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: "你身上有一种让人想靠近的魔力。你的心中装着比个人更大的东西——爱与使命感是你最强的驱动力。机会总是向你涌来,因为你值得。你天生自带好运体质。但不需要抓住所有机会,挑一个最让你心动的,全力以赴。你的存在,本身就是一种治愈。",
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("今日免费次数已用完")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "a)
|
||||
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
|
||||
}
|
||||
@@ -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{"我需要一点时间感受一下,再回复你。", "我不是拖延,我在整理自己。"},
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "把行动翻译成语言:你在调节,以及你何时回来继续谈。"},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "行动调节型",
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
请用中文做「有结构的详细回复」(约 280–450 字),建议结构:
|
||||
1)先回应用户当下问题(2–3 句)
|
||||
2)结合档案风格做一层分析(性格/沟通/关系/情绪/生活节奏中相关的 1–2 维)
|
||||
3)给出 2–4 条可执行小建议(尽量具体到本周可做)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
"我的沟通方式:",
|
||||
"这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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 // 55–95
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"` // 0–30 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)
|
||||
}
|
||||
}
|
||||
@@ -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}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 0–100 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user